diff --git a/sdk/packages/agents/README.md b/sdk/packages/agents/README.md index 54e3b1333b..d2afa25c3a 100644 --- a/sdk/packages/agents/README.md +++ b/sdk/packages/agents/README.md @@ -206,6 +206,40 @@ new Agent({ For richer, host-side hook orchestration (15-stage `HookEngine`, subprocess-backed hooks, MCP extensions), use `@cline/core`. +### Request Projection with `prepareTurn` + +`prepareTurn` is a request projection hook. It runs during turn preparation and +may return a different message list or system prompt for the next provider +request: + +```text +canonical transcript + | + | turn preparation + v +prepareTurn + | + v +provider request +``` + +Returned messages affect only the provider request for the current model call. +They do not replace the runtime's canonical transcript, are not persisted as +session history, and are not returned from `AgentRunResult.messages`. + +```text +prepareTurn returns projected messages + | + +--> provider request: yes + +--> canonical transcript: no + +--> persisted transcript: no + +--> AgentRunResult.messages: no +``` + +This is intentionally different from a transcript rewrite. Hosts that need +durable redaction, normalization, or policy filtering must apply that change +before a message enters the agent's canonical transcript. + ### Plugins Plugins can contribute tools and hooks at setup time: diff --git a/sdk/packages/agents/src/agent-runtime.test.ts b/sdk/packages/agents/src/agent-runtime.test.ts index 0da9cc4ca9..dcaabfe991 100644 --- a/sdk/packages/agents/src/agent-runtime.test.ts +++ b/sdk/packages/agents/src/agent-runtime.test.ts @@ -207,7 +207,7 @@ describe("AgentRuntime", () => { ).toBe(true); }); - it("injects pending user messages before prepareTurn rewrites the transcript", async () => { + it("injects pending user messages before prepareTurn projects the provider request", async () => { const consumePendingUserMessage = vi.fn(() => "steer before prepare"); const prepareTurn = vi.fn( (context: { messages: readonly AgentMessage[] }) => ({ @@ -261,7 +261,7 @@ describe("AgentRuntime", () => { }); }); - it("lets prepareTurn compact tool results after pending user input is added", async () => { + it("lets prepareTurn project tool results after pending user input is added", async () => { const consumePendingUserMessage = vi.fn(() => "latest steering"); const hugeToolOutput = "x".repeat(100_000); const prepareTurn = vi.fn( @@ -1180,11 +1180,11 @@ describe("AgentRuntime", () => { expect(model.requests).toHaveLength(0); }); - it("runs prepareTurn before beforeModel without overwriting canonical messages", async () => { - const compactedMessage: AgentMessage = { - id: "msg_compacted", + it("projects the provider request without overwriting canonical messages", async () => { + const projectedMessage: AgentMessage = { + id: "msg_projected", role: "user", - content: [{ type: "text", text: "compacted context" }], + content: [{ type: "text", text: "projected context" }], createdAt: 1, }; const notices: string[] = []; @@ -1197,19 +1197,19 @@ describe("AgentRuntime", () => { reason: "auto_compaction", }); return { - messages: [compactedMessage], - systemPrompt: "compacted system", + messages: [projectedMessage], + systemPrompt: "projected system", }; }); const beforeModel = vi.fn(({ request }) => { - expect(request.systemPrompt).toBe("compacted system"); - expect(request.messages).toEqual([compactedMessage]); + expect(request.systemPrompt).toBe("projected system"); + expect(request.messages).toEqual([projectedMessage]); return undefined; }); const model = new ScriptedModel([ (request) => { - expect(request.systemPrompt).toBe("compacted system"); - expect(request.messages).toEqual([compactedMessage]); + expect(request.systemPrompt).toBe("projected system"); + expect(request.messages).toEqual([projectedMessage]); return [ { type: "text-delta", text: "done" }, { type: "finish", reason: "stop" }, @@ -1233,11 +1233,13 @@ describe("AgentRuntime", () => { expect(prepareTurn).toHaveBeenCalledTimes(1); expect(beforeModel).toHaveBeenCalledTimes(1); expect(notices).toEqual(["auto-compacting"]); + expect(model.requests[0]?.messages).toEqual([projectedMessage]); expect(result.messages[0]).toMatchObject({ role: "user", content: [{ type: "text", text: "large context" }], }); expect(result.messages).toHaveLength(2); + expect(result.messages).not.toContainEqual(projectedMessage); expect(model.requests).toHaveLength(1); }); @@ -1343,16 +1345,16 @@ describe("AgentRuntime", () => { }); it("preserves the existing system prompt when prepareTurn returns only messages", async () => { - const compactedMessage: AgentMessage = { - id: "msg_compacted", + const projectedMessage: AgentMessage = { + id: "msg_projected", role: "user", - content: [{ type: "text", text: "compacted context" }], + content: [{ type: "text", text: "projected context" }], createdAt: 1, }; const model = new ScriptedModel([ (request) => { expect(request.systemPrompt).toBe("original system"); - expect(request.messages).toEqual([compactedMessage]); + expect(request.messages).toEqual([projectedMessage]); return [ { type: "text-delta", text: "done" }, { type: "finish", reason: "stop" }, @@ -1362,7 +1364,7 @@ describe("AgentRuntime", () => { const runtime = new AgentRuntime({ model, systemPrompt: "original system", - prepareTurn: () => ({ messages: [compactedMessage] }), + prepareTurn: () => ({ messages: [projectedMessage] }), }); await runtime.run("large context"); diff --git a/sdk/packages/shared/src/agent.ts b/sdk/packages/shared/src/agent.ts index 31afcfe05e..fd1032516b 100644 --- a/sdk/packages/shared/src/agent.ts +++ b/sdk/packages/shared/src/agent.ts @@ -438,9 +438,11 @@ export interface AgentRuntimeConfig { request: ToolApprovalRequest, ) => Promise | ToolApprovalResult; /** - * Optional host-owned context pipeline that can project the transcript before - * each model request. Returned messages affect the provider request only; the - * runtime's canonical in-memory transcript remains append-only. + * Optional host-owned request projection hook invoked before each model call. + * + * Returned messages affect only the provider request for the current call. + * They do not replace the canonical runtime transcript, are not persisted as + * session history, and are not reflected in AgentRunResult.messages. */ prepareTurn?: ( context: AgentRuntimePrepareTurnContext, diff --git a/sdk/packages/shared/src/agents/types.ts b/sdk/packages/shared/src/agents/types.ts index d047d217b7..0403bed86a 100644 --- a/sdk/packages/shared/src/agents/types.ts +++ b/sdk/packages/shared/src/agents/types.ts @@ -800,9 +800,14 @@ export interface AgentConfig { */ logger?: BasicLogger; /** - * Optional callback that can project the turn input before each model call. - * Returned messages affect the provider request only; the canonical runtime - * transcript remains append-only. + * Optional request projection hook invoked before each model call. + * + * Returned messages affect only the provider request for the current call. + * They do not replace the canonical runtime transcript, are not persisted as + * session history, and are not reflected in AgentRunResult.messages. + * + * Hosts that need durable redaction or normalization must apply it before a + * message enters the canonical transcript. */ prepareTurn?: ( context: AgentPrepareTurnContext,