feat(x): reference-based model requests + wire-form composer

model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).

ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.

Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).

Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 12:46:23 +05:30
parent a58493b545
commit 3822bf53da
18 changed files with 524 additions and 227 deletions

View file

@ -118,9 +118,7 @@ function requested(
ts: TS,
modelCallIndex: index,
request: {
systemPrompt: "SYS",
messages,
tools: [echoTool, fetchTool],
parameters: {},
...requestOverrides,
},
@ -348,13 +346,13 @@ function syncToolSequence(): TEvent[] {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
return [
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
invocation("tc1"),
result("tc1", "echo"),
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
completed(1, assistantText("done")),
turnCompletedEv(),
];
@ -395,7 +393,7 @@ describe("plain completion", () => {
it("reduces a plain model response to a completed turn", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
stepEvent(0),
completed(0, assistantText("done")),
turnCompletedEv(),
@ -420,7 +418,7 @@ describe("plain completion", () => {
it("leaves usage fields undefined when never reported", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a"), { inputTokens: 5 }),
]);
expect(state.usage).toEqual({ inputTokens: 5 });
@ -436,7 +434,7 @@ describe("tool execution", () => {
);
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
]);
expect(state.toolCalls).toHaveLength(2);
@ -459,7 +457,7 @@ describe("tool execution", () => {
it("leaves identity undefined for tools missing from the agent snapshot", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantCalls(toolCallPart("x1", "unknown-tool"))),
result("x1", "unknown-tool", "runtime", "No such tool", true),
]);
@ -482,7 +480,7 @@ describe("tool execution", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created({ config: { autoPermission: true, humanAvailable: true, maxModelCalls: 20 } }),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permClassified("tc1", "allow"),
@ -499,7 +497,7 @@ describe("tool execution", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permClassificationFailed(["tc1"]),
@ -514,7 +512,7 @@ describe("tool execution", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
progress("tc1"),
@ -528,7 +526,7 @@ describe("tool execution", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "deny"),
@ -543,7 +541,7 @@ describe("context references", () => {
it("accepts a referenced context with matching contextRef on requests", () => {
const state = reduceTurn([
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, [user("hello")], {
requested(0, [{ kind: "input" }], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantText("done")),
@ -556,7 +554,7 @@ describe("context references", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
],
/contextRef inconsistent/,
);
@ -566,7 +564,7 @@ describe("context references", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, [user("hello")], {
requested(0, [{ kind: "input" }], {
contextRef: { previousTurnId: "some-other-turn" },
}),
],
@ -578,7 +576,7 @@ describe("context references", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")], {
requested(0, [{ kind: "input" }], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
],
@ -586,7 +584,7 @@ describe("context references", () => {
);
});
it("ignores tool messages inside an inline context prefix when checking order", () => {
it("requires a {context} ref before {input} when inline context is nonempty", () => {
const context = [
user("earlier"),
assistantCalls(toolCallPart("old1", "echo")),
@ -594,11 +592,38 @@ describe("context references", () => {
];
const state = reduceTurn([
created({ context }),
requested(0, [...context, user("hello")]),
requested(0, [{ kind: "context" }, { kind: "input" }]),
completed(0, assistantText("done")),
turnCompletedEv(),
]);
expect(deriveTurnStatus(state)).toBe("completed");
expectCorruption(
[created({ context }), requested(0, [{ kind: "input" }])],
/references do not match/,
);
});
it("rejects a contextRef on a non-initial model call", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, [{ kind: "input" }], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantCalls(toolCallPart("tc1", "echo"))),
invocation("tc1"),
result("tc1", "echo"),
requested(
1,
[
{ kind: "assistant", modelCallIndex: 0 },
{ kind: "toolResult", toolCallId: "tc1" },
],
{ contextRef: { previousTurnId: PREV_TURN_ID } },
),
],
/contextRef on a non-initial model call/,
);
});
});
@ -610,7 +635,7 @@ describe("suspension", () => {
);
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
@ -628,7 +653,7 @@ describe("suspension", () => {
);
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
@ -648,7 +673,7 @@ describe("suspension", () => {
);
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("a1", fetchTool),
invocation("a2", fetchTool),
@ -657,10 +682,9 @@ describe("suspension", () => {
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
result("a1", "fetch", "async", "first"),
requested(1, [
user("hello"),
call0,
toolMsg("a1", "fetch", "first"),
toolMsg("a2", "fetch", "second"),
{ kind: "assistant", modelCallIndex: 0 },
{ kind: "toolResult", toolCallId: "a1" },
{ kind: "toolResult", toolCallId: "a2" },
]),
completed(1, assistantText("done")),
turnCompletedEv(),
@ -673,7 +697,7 @@ describe("suspension", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
@ -693,7 +717,7 @@ describe("suspension", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
@ -707,7 +731,7 @@ describe("suspension", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
suspendedEv([{ id: "tc1", name: "echo" }], []),
],
/suspension while a model call is unsettled/,
@ -719,9 +743,9 @@ describe("recovery-shaped histories", () => {
it("accepts an interrupted model call closed and re-issued (§23 fix)", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
callFailed(0, "interrupted by process restart"),
requested(1, [user("hello")]),
requested(1, []),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
@ -736,9 +760,9 @@ describe("recovery-shaped histories", () => {
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
callFailed(0, "interrupted"),
requested(1, [user("hello")]),
requested(1, []),
],
/exceeds maxModelCalls/,
);
@ -748,7 +772,7 @@ describe("recovery-shaped histories", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result(
@ -758,7 +782,7 @@ describe("recovery-shaped histories", () => {
"Tool execution was interrupted; its outcome is unknown and it was not retried.",
true,
),
requested(1, [user("hello"), call0, toolMsg("tc1", "echo", "…")]),
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
@ -770,7 +794,7 @@ describe("recovery-shaped histories", () => {
const call0 = assistantCalls(toolCallPart("a1", "fetch"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("a1", fetchTool),
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
@ -784,7 +808,7 @@ describe("recovery-shaped histories", () => {
it("accepts a live model failure closing the turn", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
callFailed(0),
turnFailedEv("provider exploded"),
]);
@ -797,7 +821,7 @@ describe("recovery-shaped histories", () => {
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
@ -816,7 +840,7 @@ describe("invariants", () => {
});
it("rejects a log not starting with turn_created", () => {
expectCorruption([requested(0, [user("hello")])], /first event must be turn_created/);
expectCorruption([requested(0, [{ kind: "input" }])], /first event must be turn_created/);
});
it("rejects duplicate turn_created", () => {
@ -825,7 +849,7 @@ describe("invariants", () => {
it("rejects mismatched turn ids", () => {
expectCorruption(
[created(), { ...requested(0, [user("hello")]), turnId: "other" }],
[created(), { ...requested(0, [{ kind: "input" }]), turnId: "other" }],
/does not match turn/,
);
});
@ -849,7 +873,7 @@ describe("invariants", () => {
it("rejects out-of-order model call indices", () => {
expectCorruption(
[created(), requested(1, [user("hello")])],
[created(), requested(1, [])],
/out of order/,
);
});
@ -858,9 +882,9 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a")),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
],
/out of order/,
);
@ -868,7 +892,7 @@ describe("invariants", () => {
it("rejects concurrent unresolved model requests", () => {
expectCorruption(
[created(), requested(0, [user("hello")]), requested(1, [user("hello")])],
[created(), requested(0, [{ kind: "input" }]), requested(1, [])],
/concurrent unresolved model call requests/,
);
});
@ -888,7 +912,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a")),
completed(0, assistantText("b")),
],
@ -900,7 +924,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
callFailed(0),
completed(0, assistantText("a")),
],
@ -913,9 +937,9 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
requested(1, [user("hello"), call0]),
requested(1, [{ kind: "assistant", modelCallIndex: 0 }]),
],
/while tool calls are unresolved/,
);
@ -928,11 +952,11 @@ describe("invariants", () => {
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
],
/exceeds maxModelCalls/,
);
@ -942,7 +966,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(
0,
assistantCalls(toolCallPart("tc1", "echo"), toolCallPart("tc1", "echo")),
@ -957,18 +981,18 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]),
requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]),
completed(1, assistantCalls(toolCallPart("tc1", "echo"))),
],
/duplicate tool call id/,
);
});
it("rejects tool-result ordering that differs from original call order", () => {
it("rejects request references whose tool-result order differs from the batch", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("tc2", "echo"),
@ -976,26 +1000,25 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
invocation("tc2"),
result("tc2", "echo"),
requested(1, [
user("hello"),
call0,
toolMsg("tc2", "echo"),
toolMsg("tc1", "echo"),
{ kind: "assistant", modelCallIndex: 0 },
{ kind: "toolResult", toolCallId: "tc2" },
{ kind: "toolResult", toolCallId: "tc1" },
]),
],
/tool-result order differs/,
/references do not match/,
);
});
it("rejects permission records targeting unknown tool calls", () => {
expectCorruption(
[created(), requested(0, [user("hello")]), completed(0, assistantText("a")), permRequired("ghost", "echo")],
[created(), requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), permRequired("ghost", "echo")],
/unknown tool call/,
);
});
@ -1005,7 +1028,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permRequired("tc1", "echo"),
@ -1019,7 +1042,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permClassified("tc1", "allow"),
],
@ -1032,7 +1055,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permResolved("tc1", "allow"),
],
@ -1045,7 +1068,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
@ -1060,7 +1083,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("tc1"),
@ -1074,7 +1097,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "deny"),
@ -1089,7 +1112,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
invocation("tc1"),
@ -1103,7 +1126,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1", echoTool, { execution: "async" }),
],
@ -1114,7 +1137,7 @@ describe("invariants", () => {
it("rejects progress without invocation", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, [user("hello")]), completed(0, call0), progress("tc1")],
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), progress("tc1")],
/progress without invocation/,
);
});
@ -1124,7 +1147,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
@ -1139,7 +1162,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
@ -1152,7 +1175,7 @@ describe("invariants", () => {
it("rejects sync-sourced results without invocation", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, [user("hello")]), completed(0, call0), result("tc1", "echo", "sync")],
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), result("tc1", "echo", "sync")],
/sync tool result without invocation/,
);
});
@ -1162,7 +1185,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo", "async"),
@ -1176,7 +1199,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a")),
stepEvent(0),
],
@ -1187,7 +1210,7 @@ describe("invariants", () => {
it("rejects completion while tool calls remain unresolved", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, [user("hello")]), completed(0, call0), turnCompletedEv()],
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnCompletedEv()],
/completion while tool calls lack terminal results/,
);
});
@ -1197,7 +1220,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
@ -1214,14 +1237,14 @@ describe("invariants", () => {
it("rejects terminal failure while tool calls remain unresolved", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, [user("hello")]), completed(0, call0), turnFailedEv()],
[created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnFailedEv()],
/failure while tool calls lack terminal results/,
);
});
it("rejects terminal events while a model call is unsettled", () => {
expectCorruption(
[created(), requested(0, [user("hello")]), turnCancelledEv()],
[created(), requested(0, [{ kind: "input" }]), turnCancelledEv()],
/cancellation while a model call is unsettled/,
);
});
@ -1229,7 +1252,7 @@ describe("invariants", () => {
it("rejects any event after a terminal event", () => {
const base = [
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a")),
];
for (const terminal of [turnCompletedEv(), turnFailedEv(), turnCancelledEv()]) {
@ -1244,7 +1267,7 @@ describe("invariants", () => {
expectCorruption(
[
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, assistantText("a")),
turnCompletedEv(),
turnFailedEv(),
@ -1259,14 +1282,14 @@ describe("derivations", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const pending = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
permRequired("tc1", "echo"),
suspendedEv([{ id: "tc1", name: "echo" }], []),
]);
expect(deriveTurnStatus(pending)).toBe("suspended");
const idle = reduceTurn([created(), requested(0, [user("hello")]), completed(0, call0)]);
const idle = reduceTurn([created(), requested(0, [{ kind: "input" }]), completed(0, call0)]);
expect(deriveTurnStatus(idle)).toBe("idle");
});
@ -1277,17 +1300,16 @@ describe("derivations", () => {
);
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
invocation("tc1"),
invocation("tc2"),
result("tc2", "echo", "sync", { n: 2 }),
result("tc1", "echo", "sync", "plain text"),
requested(1, [
user("hello"),
call0,
toolMsg("tc1", "echo", "plain text"),
toolMsg("tc2", "echo", '{"n":2}'),
{ kind: "assistant", modelCallIndex: 0 },
{ kind: "toolResult", toolCallId: "tc1" },
{ kind: "toolResult", toolCallId: "tc2" },
]),
completed(1, assistantText("done")),
turnCompletedEv(),
@ -1304,9 +1326,9 @@ describe("derivations", () => {
it("omits failed model calls from the transcript", () => {
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
callFailed(0, "interrupted"),
requested(1, [user("hello")]),
requested(1, []),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
@ -1316,7 +1338,7 @@ describe("derivations", () => {
it("transcript excludes the context prefix", () => {
const state = reduceTurn([
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, [user("hello")], {
requested(0, [{ kind: "input" }], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantText("done")),
@ -1330,7 +1352,7 @@ describe("derivations", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, [user("hello")]),
requested(0, [{ kind: "input" }]),
completed(0, call0),
]);
expect(() => turnTranscript(state)).toThrowError(/unresolved/);

View file

@ -119,16 +119,30 @@ export const TurnCreated = z.object({
}),
});
// `messages` contains current-turn messages only; when the turn's context is
// a reference, `contextRef` repeats it and the resolved prefix is implicit
// (deterministically materializable because referenced turns are immutable).
// When context is inline, `contextRef` is absent and `messages` begins with
// the inline context.
// A model request is a list of REFERENCES into the turn's own events; every
// referenced byte exists exactly once in the file. The request records only
// what is NEW since the previous model call:
// call 0: [{context}?, {input}] (context only when inline and nonempty;
// cross-turn prefixes ride contextRef)
// call N: [{assistant: N-1}, ...that batch's toolResults in source order]
// re-issue after an interrupted call: []
// The system prompt and tool set are NOT repeated here — they are byte-
// identical to turn_created.agent.resolved by construction. The exact
// provider payload is rebuilt deterministically by the request composer
// (core), which is the same code path the loop sends through.
export const ModelRequestMessageRef = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("context") }),
z.object({ kind: z.literal("input") }),
z.object({
kind: z.literal("assistant"),
modelCallIndex: z.number().int().nonnegative(),
}),
z.object({ kind: z.literal("toolResult"), toolCallId: z.string() }),
]);
export const ModelRequest = z.object({
systemPrompt: z.string(),
contextRef: TurnContextRef.optional(),
messages: z.array(ConversationMessage),
tools: z.array(ToolDescriptor),
messages: z.array(ModelRequestMessageRef),
parameters: z.record(z.string(), z.json()),
});
@ -491,32 +505,44 @@ function applyModelCallRequested(
}
const context = state.definition.context;
if (isContextRef(context)) {
if (event.request.contextRef?.previousTurnId !== context.previousTurnId) {
fail("model request contextRef inconsistent with turn context");
let expectedRefs: Array<z.infer<typeof ModelRequestMessageRef>>;
if (event.modelCallIndex === 0) {
if (isContextRef(context)) {
if (event.request.contextRef?.previousTurnId !== context.previousTurnId) {
fail("model request contextRef inconsistent with turn context");
}
expectedRefs = [{ kind: "input" }];
} else {
if (event.request.contextRef !== undefined) {
fail("model request has contextRef but turn context is inline");
}
expectedRefs =
context.length > 0
? [{ kind: "context" }, { kind: "input" }]
: [{ kind: "input" }];
}
} else if (event.request.contextRef !== undefined) {
fail("model request has contextRef but turn context is inline");
} else {
if (event.request.contextRef !== undefined) {
fail("model request has contextRef on a non-initial model call");
}
const previous = state.modelCalls[event.modelCallIndex - 1];
expectedRefs =
previous.response !== undefined
? [
{ kind: "assistant", modelCallIndex: previous.index },
...batchToolCalls(state, previous.index).map((tc) => ({
kind: "toolResult" as const,
toolCallId: tc.toolCallId,
})),
]
: []; // re-issue after an interrupted call adds nothing new
}
// Model-facing tool-result order must match original call order: the tool
// messages in the request (past any inline context prefix) must be the
// concatenated batches of prior completed calls, in source order.
const prefixLength = isContextRef(context) ? 0 : context.length;
const toolMessageIds = event.request.messages
.slice(prefixLength)
.filter((m) => m.role === "tool")
.map((m) => m.toolCallId);
const expectedIds = state.modelCalls
.filter((call) => call.response !== undefined)
.flatMap((call) =>
batchToolCalls(state, call.index).map((tc) => tc.toolCallId),
if (JSON.stringify(event.request.messages) !== JSON.stringify(expectedRefs)) {
fail(
`model request references do not match the transcript: expected ${JSON.stringify(
expectedRefs,
)}, got ${JSON.stringify(event.request.messages)}`,
);
if (
toolMessageIds.length !== expectedIds.length ||
toolMessageIds.some((id, i) => id !== expectedIds[i])
) {
fail("model request tool-result order differs from original call order");
}
state.modelCalls.push({
@ -932,6 +958,69 @@ function toolResultContent(result: z.infer<typeof ToolResult>): string {
return typeof output === "string" ? output : JSON.stringify(output);
}
// The canonical model-facing tool message for a resolved tool call.
export function toolResultMessage(
toolCall: ToolCallState,
): z.infer<typeof ConversationMessage> {
if (!toolCall.result) {
throw new Error(
`tool call ${toolCall.toolCallId} has no terminal result`,
);
}
return {
role: "tool",
content: toolResultContent(toolCall.result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
}
// Resolves one model call's request references to structural messages (the
// NEW portion that call added relative to the previous one). Concatenating
// calls 0..N yields the full current-turn conversation for call N; the
// request composer (core) prepends the resolved cross-turn prefix and
// encodes to the provider wire form.
export function requestMessagesFor(
state: TurnState,
modelCallIndex: number,
): Array<z.infer<typeof ConversationMessage>> {
const call = state.modelCalls[modelCallIndex];
if (!call) {
throw new Error(`no model call at index ${modelCallIndex}`);
}
return call.request.messages.flatMap((ref) => {
switch (ref.kind) {
case "context": {
const context = state.definition.context;
if (!Array.isArray(context)) {
throw new Error("context ref on a turn without inline context");
}
return context;
}
case "input":
return [state.definition.input];
case "assistant": {
const response = state.modelCalls[ref.modelCallIndex]?.response;
if (response === undefined) {
throw new Error(
`assistant ref to unsettled model call ${ref.modelCallIndex}`,
);
}
return [response];
}
case "toolResult": {
const toolCall = state.toolCalls.find(
(tc) => tc.toolCallId === ref.toolCallId,
);
if (!toolCall) {
throw new Error(`toolResult ref to unknown call ${ref.toolCallId}`);
}
return [toolResultMessage(toolCall)];
}
}
});
}
// The messages this turn contributed to the conversation: its input plus, per
// completed model call, the assistant response and its tool results in source
// order. Failed model calls contribute nothing. The turn's context prefix is
@ -955,12 +1044,7 @@ export function turnTranscript(
`turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`,
);
}
messages.push({
role: "tool",
content: toolResultContent(toolCall.result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
});
messages.push(toolResultMessage(toolCall));
}
}
return messages;