diff --git a/mcp/src/graph/neo4j.ts b/mcp/src/graph/neo4j.ts index fd535f2f4..985abb3c0 100644 --- a/mcp/src/graph/neo4j.ts +++ b/mcp/src/graph/neo4j.ts @@ -1664,6 +1664,15 @@ class Db { } } + async set_agent_session_summary(session_id: string, summary: string): Promise { + const session = this.resilientSession(); + try { + await session.run(Q.SET_AGENT_SESSION_SUMMARY_QUERY, { session_id, summary }); + } finally { + await session.close(); + } + } + async list_agent_sessions(): Promise { const session = this.resilientSession(); try { diff --git a/mcp/src/graph/queries.ts b/mcp/src/graph/queries.ts index e84f20f31..1096bc231 100644 --- a/mcp/src/graph/queries.ts +++ b/mcp/src/graph/queries.ts @@ -332,6 +332,12 @@ SET n.end_time = toInteger($end_time), RETURN n `; +export const SET_AGENT_SESSION_SUMMARY_QUERY = ` +MATCH (n:AgentSession:${Data_Bank} {node_key: $session_id}) +SET n.summary = $summary +RETURN n.node_key +`; + export const LIST_AGENT_SESSIONS_QUERY = ` MATCH (n:AgentSession) WHERE n.file = 'session://generated' diff --git a/mcp/src/repo/__tests__/agent-instructions.test.ts b/mcp/src/repo/__tests__/agent-instructions.test.ts new file mode 100644 index 000000000..c1238188f --- /dev/null +++ b/mcp/src/repo/__tests__/agent-instructions.test.ts @@ -0,0 +1,110 @@ +/** + * Regression tests for the system-prompt wiring of /repo/agent. + * + * PR #1494 silently dropped `instructions` from the `new ToolLoopAgent({...})` + * call in src/repo/agent.ts. Nothing caught it: `instructions` is optional so + * tsc stayed green, and the existing agent tests reproduce logic inline rather + * than exercising the real construction. The agent ran with no system prompt at + * all — no repo info, no org-agent block, no sub-agent block, no skills index, + * no systemOverride — until the change was reverted in #1497. + * + * These tests pin the two facts that made that one-line deletion fatal: + * 1. the constructor's `instructions` is what becomes the model's `system`; + * 2. there is no per-call channel for it, so the constructor is the only one. + * + * The last test guards the construction site itself, which is the only way to + * catch a re-deletion: the type system cannot, because the field is optional. + */ + +import { test, expect } from "../../testkit.js"; +import { ToolLoopAgent } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const INSTRUCTIONS = "SYSTEM_PROMPT_UNDER_TEST"; + +function mockModel(): MockLanguageModelV3 { + return new MockLanguageModelV3({ + doGenerate: async () => + ({ + content: [{ type: "text", text: "ok" }], + finishReason: "stop", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + warnings: [], + }) as any, + }); +} + +function systemOf(model: MockLanguageModelV3, callIndex = 0): string | undefined { + const prompt = model.doGenerateCalls[callIndex]?.prompt as any[]; + return prompt?.find((m) => m.role === "system")?.content; +} + +test.describe("/repo/agent system-prompt wiring (ToolLoopAgent contract)", () => { + test("constructor `instructions` reaches the model as the system message", async () => { + const model = mockModel(); + const agent = new ToolLoopAgent({ model, instructions: INSTRUCTIONS }); + + await agent.generate({ prompt: "hello" }); + + expect(model.doGenerateCalls.length).toBe(1); + expect(systemOf(model)).toBe(INSTRUCTIONS); + }); + + test("omitting `instructions` sends no system message at all — the #1494 regression", async () => { + const model = mockModel(); + const agent = new ToolLoopAgent({ model }); + + await agent.generate({ prompt: "hello" }); + + // This is exactly what shipped in #1494: a model call with no system turn. + expect(systemOf(model)).toBe(undefined); + }); + + test("a per-call `messages` array cannot supply the system prompt — the constructor is the only channel", async () => { + const model = mockModel(); + const agent = new ToolLoopAgent({ model, instructions: INSTRUCTIONS }); + + await agent.generate({ + messages: [{ role: "user", content: "hello" }], + }); + + // Callers pass `messages`; the system turn still comes from the constructor. + expect(systemOf(model)).toBe(INSTRUCTIONS); + }); + + test("`instructions` applies to every call on the same agent, including the summarization pass", async () => { + const model = mockModel(); + const agent = new ToolLoopAgent({ model, instructions: INSTRUCTIONS }); + + await agent.generate({ prompt: "the turn" }); + // summarizeAfterTurn re-uses `prepared.agent` and passes only `messages`. + await agent.generate({ + messages: [{ role: "user", content: "summarize the session" }], + }); + + expect(model.doGenerateCalls.length).toBe(2); + expect(systemOf(model, 0)).toBe(INSTRUCTIONS); + expect(systemOf(model, 1)).toBe(INSTRUCTIONS); + }); + + test("agent.ts still passes `instructions` when constructing ToolLoopAgent", () => { + const agentSrc = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "agent.ts"), + "utf-8", + ); + + const start = agentSrc.indexOf("new ToolLoopAgent({"); + expect(start).not.toBe(-1); + + // The constructor object literal ends at the first line that closes it at + // the same indentation as the `const agent = ...` statement. + const end = agentSrc.indexOf("\n });", start); + expect(end).not.toBe(-1); + + const ctorBlock = agentSrc.slice(start, end); + expect(ctorBlock.includes("instructions:")).toBe(true); + }); +}); diff --git a/mcp/src/repo/__tests__/session-summary.test.ts b/mcp/src/repo/__tests__/session-summary.test.ts new file mode 100644 index 000000000..3b803e558 --- /dev/null +++ b/mcp/src/repo/__tests__/session-summary.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for the session-summary sidecar added alongside the opt-in + * `summarize` flag on /repo/agent. + * + * The summary is written to `.summary.json` next to the session + * JSONL and surfaced via GET /repo/agent/session?summarize=true. These tests + * cover the persistence layer directly: round-trip, the two null paths, the + * graph write being a no-op without a database, and sidecar cleanup on delete + * (a leaked summary file would outlive the session it describes). + * + * session.ts reads SESSIONS_DIR at module load, so it is imported dynamically + * after the env var is set — the same pattern as graph-agent-session.test.ts. + */ + +import { test, expect } from "../../testkit.js"; +import { randomUUID } from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +const tmpSessionsDir = path.join(os.tmpdir(), `test-session-summary-${randomUUID()}`); + +type SessionModule = typeof import("../session.js"); +let sessionModule: SessionModule; + +async function getSessionModule(): Promise { + process.env.SESSIONS_DIR = tmpSessionsDir; + if (!sessionModule) { + sessionModule = await import("../session.js"); + } + return sessionModule; +} + +function summaryPath(sessionId: string): string { + return path.join(tmpSessionsDir, `${sessionId}.summary.json`); +} + +test.describe("session summary sidecar", () => { + test.beforeEach(() => { + fs.mkdirSync(tmpSessionsDir, { recursive: true }); + }); + + test.afterEach(() => { + try { + fs.rmSync(tmpSessionsDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + test("saveSessionSummary then loadSessionSummary round-trips the summary text", async () => { + const { saveSessionSummary, loadSessionSummary } = await getSessionModule(); + const sessionId = randomUUID(); + const summary = "## What happened\nTraced a regression.\n\n## Open threads\nNone."; + + saveSessionSummary(sessionId, { summary }); + + expect(fs.existsSync(summaryPath(sessionId))).toBe(true); + expect(loadSessionSummary(sessionId)?.summary).toBe(summary); + }); + + test("loadSessionSummary returns null when no summary was ever written", async () => { + const { loadSessionSummary } = await getSessionModule(); + + expect(loadSessionSummary(randomUUID())).toBe(null); + }); + + test("loadSessionSummary returns null rather than throwing on a corrupt sidecar", async () => { + const { loadSessionSummary } = await getSessionModule(); + const sessionId = randomUUID(); + + fs.writeFileSync(summaryPath(sessionId), "{ not json"); + + // A truncated write must degrade to "no summary", not break the session read. + expect(loadSessionSummary(sessionId)).toBe(null); + }); + + test("saveSummaryToGraph writes only to the graph, and is a no-op without a database", async () => { + const { saveSummaryToGraph } = await getSessionModule(); + const sessionId = randomUUID(); + + // Runs under NO_DB=true, so `db` is undefined and the optional chain short + // -circuits before `.catch`. A throw here fails the test directly; testkit's + // `.not.toThrow()` is double-negated and cannot express this. + saveSummaryToGraph(sessionId, "a summary"); + + // The graph write is independent of the file sidecar — it must not create one. + expect(fs.existsSync(summaryPath(sessionId))).toBe(false); + }); + + test("deleteSession removes the summary sidecar along with the session", async () => { + const { saveSessionSummary, loadSessionSummary, deleteSession } = await getSessionModule(); + const sessionId = randomUUID(); + + saveSessionSummary(sessionId, { summary: "to be deleted" }); + expect(fs.existsSync(summaryPath(sessionId))).toBe(true); + + deleteSession(sessionId); + + expect(fs.existsSync(summaryPath(sessionId))).toBe(false); + expect(loadSessionSummary(sessionId)).toBe(null); + }); +}); diff --git a/mcp/src/repo/agent.ts b/mcp/src/repo/agent.ts index d5eff31b8..193af2108 100644 --- a/mcp/src/repo/agent.ts +++ b/mcp/src/repo/agent.ts @@ -46,6 +46,8 @@ import { sessionExists, saveSessionConfig, saveSessionMetadata, + saveSessionSummary, + saveSummaryToGraph, SessionConfig, StepMeta, } from "./session.js"; @@ -435,6 +437,8 @@ export interface GetContextOptions { commitList?: string[]; // Skip prepending repo info to the first prompt (handler-level; recorded in config) ignoreRepoInfo?: boolean; + summarize?: boolean; + summarizePrompt?: string; // Replay mode: `prompt` is a full ModelMessage[] (including the system turn) // that is run verbatim. No system prompt is generated, no enrichment blocks // are appended, no session history is loaded, no attachments are resolved, @@ -883,14 +887,27 @@ export async function get_context( appendSearchProvenance(sessionId, provenanceCollector.entries); } + const summaryUsage = opts.summarize + ? await summarizeAfterTurn( + prepared, + newMessages, + opts.summarizePrompt || DEFAULT_SUMMARIZE_PROMPT, + ).catch((e) => { + console.error("[summarize] failed (non-fatal):", e); + return undefined; + }) + : undefined; + await appendSessionEnd(sessionId, { end_time: new Date().toISOString(), model: modelId, provider, duration_ms: duration, status: "success", - token_usage: usage, + token_usage: summaryUsage ? normalizeUsage(addUsage(usage, summaryUsage.usage)) : usage, }); + + if (summaryUsage?.summary) saveSummaryToGraph(sessionId, summaryUsage.summary); } const final = extractFinalAnswer(steps); @@ -971,14 +988,28 @@ export async function stream_context( const stepUsage = stepMetas.length > 0 ? normalizeUsage(addUsage(...stepMetas.map((step) => step.usage))) : normalizeUsage(usage); + + const summaryUsage = opts.summarize + ? await summarizeAfterTurn( + prepared, + newMessages, + opts.summarizePrompt || DEFAULT_SUMMARIZE_PROMPT, + ).catch((e) => { + console.error("[summarize] failed (non-fatal):", e); + return undefined; + }) + : undefined; + await appendSessionEnd(sessionId, { end_time: new Date().toISOString(), model: modelId, provider, duration_ms: duration, status: "success", - token_usage: stepUsage, + token_usage: summaryUsage ? normalizeUsage(addUsage(stepUsage, summaryUsage.usage)) : stepUsage, }); + + if (summaryUsage?.summary) saveSummaryToGraph(sessionId, summaryUsage.summary); } catch (e) { const aborted = isAbortError(e); if (aborted) { @@ -999,6 +1030,51 @@ export async function stream_context( }; } +export const DEFAULT_SUMMARIZE_PROMPT = `Summarize this session for a future reader who was not here. Answer directly from the conversation above — do not use any tools. + +Write prose under exactly these three headings: + +## What happened +What was asked, what was explored, and what was concluded or delivered. Name the specific files, functions, commands, and decisions. + +## Mistakes, wrong turns, and bad assumptions +This is the most important section. Record every incorrect assumption, every dead end, every search or tool call that returned nothing useful, every fix that had to be redone, and anything that behaved differently than expected. For each one, say what was believed and what turned out to be true. If the session ran clean, say so in one line. + +## Open threads +Unfinished work, unanswered questions, and anything deliberately deferred.`; + +async function summarizeAfterTurn( + prepared: PreparedAgent, + newMessages: ModelMessage[], + summarizePrompt: string, +): Promise<{ usage: ReturnType; summary: string } | undefined> { + const { sessionId } = prepared; + if (!sessionId) return; + + const params = { + messages: [ + ...prepared.previousMessages, + ...newMessages, + { role: "user", content: summarizePrompt } as ModelMessage, + ], + providerOptions: getProviderOptions(prepared.provider as any, undefined, prepared.modelId), + ...(prepared.abortSignal ? { abortSignal: prepared.abortSignal } : {}), + }; + const result = await prepared.agent.stream(params); + + const summary = extractFinalAnswer((await result.steps) ?? []).answer.trim(); + const usage = normalizeUsage(await result.totalUsage); + console.log("===> summarize_session", { + sessionId, + cache_read: usage.cache_read, + input: usage.input, + empty: !summary, + }); + + if (summary) saveSessionSummary(sessionId, { summary }); + return { usage, summary }; +} + /* curl -X POST -H "Content-Type: application/json" -d '{"repo_url": "https://github.com/stakwork/hive", "prompt": "how does auth work in the repo"}' "http://localhost:3355/repo/agent" diff --git a/mcp/src/repo/index.ts b/mcp/src/repo/index.ts index 79f04a54c..9198c264d 100644 --- a/mcp/src/repo/index.ts +++ b/mcp/src/repo/index.ts @@ -12,7 +12,7 @@ import { startTracking, endTracking } from "../busy.js"; import { services_agent } from "./services.js"; import { mocks_agent } from "./mocks.js"; import { ModelName } from "../aieo/src/index.js"; -import { SessionConfig, loadSession, loadSessionConfig, loadSessionMetadata, sessionExists } from "./session.js"; +import { SessionConfig, loadSession, loadSessionConfig, loadSessionMetadata, loadSessionSummary, sessionExists } from "./session.js"; import { McpServer } from "./mcpServers.js"; import { existsSync } from "fs"; import path from "path"; @@ -25,6 +25,7 @@ import { abortRequest, } from "./events.js"; import { db } from "../graph/neo4j.js"; +import { isTrue } from "../graph/utils.js"; import { describe_nodes_agent, embed_nodes_agent } from "./descriptions.js"; export { services_agent, mocks_agent, describe_nodes_agent, embed_nodes_agent }; @@ -158,6 +159,8 @@ function parseAgentBody(req: Request) { const maxTurns = typeof req.body.maxTurns === "number" ? req.body.maxTurns : undefined; const headers = normalizeHeaders(req.body.headers); const ignoreRepoInfo = req.body.ignoreRepoInfo as boolean | undefined; + const summarize = req.body.summarize as boolean | undefined; + const summarizePrompt = req.body.summarizePrompt as string | undefined; // Stakwork API key for the run-research tools. Server-to-server secret // (like `pat`/`apiKey`): read off the body, never exposed to the LLM. const stakworkApiKey = req.body.stakworkApiKey as string | undefined; @@ -200,7 +203,7 @@ function parseAgentBody(req: Request) { repoUrl, username, pat, commitList, prompt, messages, toolsConfig, schema, modelName, apiKey, baseUrl, logs, sessionId, sessionConfig, mcpServers, systemOverride, mode, skills, subAgents, ggnn, stream, repoList, maxTurns, headers, - ignoreRepoInfo, attachments, _metadata, webhookUrl, + ignoreRepoInfo, attachments, _metadata, webhookUrl, summarize, summarizePrompt, stakwork: stakworkApiKey ? { apiKey: stakworkApiKey, baseUrl: stakworkBaseUrl } : undefined, googleSheets, }; @@ -402,6 +405,8 @@ export async function repo_agent(req: Request, res: Response) { _metadata: body._metadata, commitList: body.commitList, ignoreRepoInfo: body.ignoreRepoInfo, + summarize: body.summarize, + summarizePrompt: body.summarizePrompt, }, ); @@ -528,6 +533,8 @@ export async function repo_agent(req: Request, res: Response) { _metadata: body._metadata, commitList: body.commitList, ignoreRepoInfo: body.ignoreRepoInfo, + summarize: body.summarize, + summarizePrompt: body.summarizePrompt, onStepEvent: (content) => { const events = filterStepContent(content); for (const ev of events) bus.emit(ev); @@ -656,7 +663,8 @@ export async function get_agent_tools(req: Request, res: Response) { export async function get_agent_session(req: Request, res: Response) { const sessionId = req.query.session_id as string || req.query.sessionId as string; - console.log("===> GET /repo/agent/session", { hasSessionId: Boolean(sessionId) }); + const summarize = isTrue((req.query.summarize as string) || ""); + console.log("===> GET /repo/agent/session", { hasSessionId: Boolean(sessionId), summarize }); if (!sessionId) { res.status(400).json({ error: "Missing session_id" }); @@ -672,7 +680,17 @@ export async function get_agent_session(req: Request, res: Response) { const messages = loadSession(sessionId); const config = loadSessionConfig(sessionId); const _metadata = loadSessionMetadata(sessionId); - res.json({ sessionId, messages, config, _metadata }); + if (!summarize) { + res.json({ sessionId, messages, config, _metadata }); + return; + } + res.json({ + sessionId, + messages, + config, + _metadata, + summary: loadSessionSummary(sessionId)?.summary ?? null, + }); } catch (e) { console.error("Error in get_agent_session", e); res.status(500).json({ error: "Internal server error" }); diff --git a/mcp/src/repo/session.ts b/mcp/src/repo/session.ts index c9ec99da1..a5f75d7fe 100644 --- a/mcp/src/repo/session.ts +++ b/mcp/src/repo/session.ts @@ -55,6 +55,10 @@ export interface SessionInitConfig { ignoreRepoInfo?: boolean; } +export interface SessionSummary { + summary: string; +} + export interface StepMeta { step: number; turn: number; @@ -229,6 +233,10 @@ export function deleteSession(sessionId: string): void { if (existsSync(metadataPath)) { unlinkSync(metadataPath); } + const summaryPath = getSummaryFile(sessionId); + if (existsSync(summaryPath)) { + unlinkSync(summaryPath); + } deleteAttachments(sessionId); } @@ -300,6 +308,36 @@ export function loadSessionMetadata(sessionId: string): unknown | null { } } +function getSummaryFile(sessionId: string): string { + const sessionDir = path.isAbsolute(SESSIONS_DIR) + ? SESSIONS_DIR + : path.join(process.cwd(), SESSIONS_DIR); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return path.join(sessionDir, `${sessionId}.summary.json`); +} + +export function saveSessionSummary(sessionId: string, summary: SessionSummary): void { + writeFileSync(getSummaryFile(sessionId), JSON.stringify(summary, null, 2)); +} + +export function saveSummaryToGraph(sessionId: string, summary: string): void { + void db + ?.set_agent_session_summary(sessionId, summary) + .catch((e: unknown) => console.error("[sessions] Neo4j summary write failed:", e)); +} + +export function loadSessionSummary(sessionId: string): SessionSummary | null { + const filePath = getSummaryFile(sessionId); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as SessionSummary; + } catch { + return null; + } +} + /** * Get the file path for a session's per-step metadata sidecar. * This is separate from the conversation JSONL to avoid corrupting session replay. @@ -544,6 +582,8 @@ export function pruneExpiredSessions(): number { if (existsSync(annPath)) unlinkSync(annPath); const configPath = filePath.replace(/\.jsonl$/, ".config.json"); if (existsSync(configPath)) unlinkSync(configPath); + const summaryPath = filePath.replace(/\.jsonl$/, ".summary.json"); + if (existsSync(summaryPath)) unlinkSync(summaryPath); deleteAttachments(sessionId); pruned++; }