Clarify prepareTurn request projection semantics

This commit is contained in:
Robin Newhouse
2026-06-26 09:56:24 -07:00
parent f4e505a4d4
commit 9ce0e5501a
4 changed files with 66 additions and 23 deletions
+34
View File
@@ -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:
+19 -17
View File
@@ -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");
+5 -3
View File
@@ -438,9 +438,11 @@ export interface AgentRuntimeConfig {
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult> | 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,
+8 -3
View File
@@ -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,