Skip to content

Commit 2f4cc5e

Browse files
committed
fix(runner): stop replaying the current prompt as a prior turn
The runner persists the inbound user message before it starts the engine, and acquiring a sandbox takes seconds, so by the time reconstruction reads the record log the current turn is already in it. Reconstruction returned that record as a prior turn and the inbound message was appended on top, sending the prompt to the model twice. It reproduced on every first turn of every conversation, including with the frontend flag off, because a first turn always carries exactly one message and the `messages.length > 1` guard does not stop it. Drop the current turn's records by `turn_id` before folding, and replace the message-count guard with a shared `carriesMinimalHistory` predicate that both this seam and the keep-alive check can agree on. The count alone also let reconstruction run for an empty array and for a lone assistant message. Claude-Session: https://claude.ai/code/session_01KM69J7uHafgciiN5zfG7qR
1 parent 068f296 commit 2f4cc5e

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

services/runner/src/engines/sandbox_agent/reconstruct-history.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
* trusting a full inbound history — the server side of "client sends only the last message".
44
*
55
* Flag-gated (`AGENTA_SESSIONS_RECONSTRUCT`) and a strict no-op until BOTH the flag is on AND the
6-
* client actually sent a minimal history: when the client still sends the whole conversation
7-
* (`messages.length > 1`), reconstruction is skipped and behaviour is unchanged. Best-effort — any
8-
* miss (no session, no records, fetch failure) leaves the inbound history untouched.
6+
* client actually sent a minimal history (`carriesMinimalHistory`). Best-effort — any miss (no
7+
* session, no records, fetch failure) leaves the inbound history untouched.
8+
*
9+
* The record log already contains the CURRENT turn by the time this runs: the runner persists the
10+
* inbound user message before it starts the engine, and acquiring a sandbox takes seconds. Its
11+
* records are therefore dropped by `turn_id` here, or the current prompt would be reconstructed
12+
* as a prior turn and then appended again from the inbound history.
913
*/
1014

1115
import type { AgentRunRequest } from "../../protocol.ts";
1216
import { fetchSessionRecords } from "../../sessions/records-query.ts";
1317
import { reconstructMessages } from "../../sessions/reconstruct.ts";
18+
import { carriesMinimalHistory } from "./session-identity.ts";
1419

1520
function reconstructEnabled(): boolean {
1621
return (
@@ -21,9 +26,6 @@ function reconstructEnabled(): boolean {
2126
/**
2227
* Returns a request whose `messages` are `[...reconstructed prior turns, ...inbound]` when
2328
* reconstruction applies, else `null` to keep the inbound history as-is.
24-
*
25-
* MUST be called before the current turn's user message is persisted, so the record log holds
26-
* only prior turns (no duplication of the incoming prompt).
2729
*/
2830
export async function reconstructHistoryIfNeeded(
2931
request: AgentRunRequest,
@@ -33,18 +35,26 @@ export async function reconstructHistoryIfNeeded(
3335
): Promise<AgentRunRequest | null> {
3436
if (!reconstructEnabled() || !sessionId) return null;
3537
const inbound = request.messages ?? [];
36-
// The client already sent the conversation — nothing to rebuild.
37-
if (inbound.length > 1) return null;
38+
// The client still asserts the conversation itself — nothing to rebuild.
39+
if (!carriesMinimalHistory(request)) return null;
3840

3941
const records = await fetchSessionRecords(sessionId, auth);
40-
if (!records || records.length === 0) return null;
42+
if (!records) return null;
43+
44+
// Drop this turn's own records: the inbound message already carries the current prompt.
45+
const currentTurnId = request.turnId?.trim();
46+
const prior = currentTurnId
47+
? records.filter((row) => row.turn_id !== currentTurnId)
48+
: records;
49+
if (prior.length === 0) return null;
4150

42-
const reconstructed = reconstructMessages(records);
51+
const reconstructed = reconstructMessages(prior);
4352
if (reconstructed.length === 0) return null;
4453

4554
log?.(
4655
`[reconstruct] session=${sessionId} records=${records.length} ` +
47-
`priorMessages=${reconstructed.length} inbound=${inbound.length}`,
56+
`prior=${prior.length} priorMessages=${reconstructed.length} ` +
57+
`inbound=${inbound.length}`,
4858
);
4959
return { ...request, messages: [...reconstructed, ...inbound] };
5060
}

services/runner/src/engines/sandbox_agent/session-identity.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,20 @@ export function approvalDecisionForToolCall(
290290
return undefined;
291291
}
292292

293+
/**
294+
* True when the request carries exactly its own fresh user turn and no prior conversation —
295+
* what a last-message-only client sends. The single predicate both sides agree on: the runner
296+
* reconstructs prior turns only for such a request, and the keep-alive check skips its history
297+
* comparison for one, because the client is no longer asserting the conversation at all.
298+
*
299+
* A message count alone is NOT enough: turn one of any conversation is also a single message,
300+
* and a lone assistant message or an empty array are neither a fresh turn nor a full history.
301+
*/
302+
export function carriesMinimalHistory(request: AgentRunRequest): boolean {
303+
const messages = request.messages ?? [];
304+
return messages.length === 1 && tailIsFreshUserMessage(request);
305+
}
306+
293307
/**
294308
* True when the request's tail is a fresh user message with text and NOT an approval envelope.
295309
* A continuation only takes the live path for a plain new user turn; an approval reply (a

services/runner/tests/unit/session-reconstruct-history.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,49 @@ describe("reconstructHistoryIfNeeded", () => {
8888
// Other request fields are preserved.
8989
assert.equal((out as { harness?: string }).harness, "pi");
9090
});
91+
92+
it("no-op when the request carries no messages at all", async () => {
93+
vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true");
94+
const req = { messages: [] } as never;
95+
const out = await reconstructHistoryIfNeeded(req, "sess-1", auth);
96+
assert.equal(out, null);
97+
assert.equal(fetchCalls, 0);
98+
});
99+
100+
it("no-op when the single inbound message is not a fresh user turn", async () => {
101+
vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true");
102+
const req = { messages: [{ role: "assistant", content: "a1" }] } as never;
103+
const out = await reconstructHistoryIfNeeded(req, "sess-1", auth);
104+
assert.equal(out, null);
105+
assert.equal(fetchCalls, 0);
106+
});
107+
108+
it("drops the current turn's own records so the prompt is not replayed twice", async () => {
109+
vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true");
110+
// The runner persists the inbound prompt BEFORE the engine starts, so by the time this
111+
// runs the log already holds turn-2's own user record.
112+
recordsToReturn = [
113+
{ turn_id: "turn-1", record_source: "user", attributes: { type: "message", text: "q1" } },
114+
{ turn_id: "turn-1", record_source: "agent", attributes: { type: "message", text: "a1" } },
115+
{ turn_id: "turn-2", record_source: "user", attributes: { type: "message", text: "hi again" } },
116+
];
117+
const req = { messages: [userTurn], turnId: "turn-2" } as never;
118+
const out = await reconstructHistoryIfNeeded(req, "sess-1", auth);
119+
assert.ok(out);
120+
assert.deepEqual(out!.messages, [
121+
{ role: "user", content: "q1" },
122+
{ role: "assistant", content: "a1" },
123+
userTurn,
124+
]);
125+
});
126+
127+
it("no-op when the only records belong to the current turn (first turn of a session)", async () => {
128+
vi.stubEnv("AGENTA_SESSIONS_RECONSTRUCT", "true");
129+
recordsToReturn = [
130+
{ turn_id: "turn-1", record_source: "user", attributes: { type: "message", text: "hi again" } },
131+
];
132+
const req = { messages: [userTurn], turnId: "turn-1" } as never;
133+
const out = await reconstructHistoryIfNeeded(req, "sess-1", auth);
134+
assert.equal(out, null);
135+
});
91136
});

0 commit comments

Comments
 (0)