Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions mcp/src/graph/neo4j.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,15 @@ class Db {
}
}

async set_agent_session_summary(session_id: string, summary: string): Promise<void> {
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<any[]> {
const session = this.resilientSession();
try {
Expand Down
6 changes: 6 additions & 0 deletions mcp/src/graph/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
110 changes: 110 additions & 0 deletions mcp/src/repo/__tests__/agent-instructions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
103 changes: 103 additions & 0 deletions mcp/src/repo/__tests__/session-summary.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<sessionId>.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<SessionModule> {
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);
});
});
80 changes: 78 additions & 2 deletions mcp/src/repo/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
sessionExists,
saveSessionConfig,
saveSessionMetadata,
saveSessionSummary,
saveSummaryToGraph,
SessionConfig,
StepMeta,
} from "./session.js";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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<typeof normalizeUsage>; 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"

Expand Down
Loading
Loading