Skip to content

Commit 74004a4

Browse files
authored
fix: fall back to live context when assembly lacks user turns
Reviewed as no-fix-needed. Validation from review worker: focused engine tests passed; TypeScript no-emit failure reproduced on origin/main and was treated as baseline noise. Includes patch changeset .changeset/fix-cold-cache-prefill-error.md.
1 parent ab22632 commit 74004a4

3 files changed

Lines changed: 95 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@martian-engineering/lossless-claw": patch
3+
---
4+
5+
Fix prefill errors on cold-cache new sessions that start with only an assistant greeting.
6+
7+
When a session begins with an agent greeting before any user message and the Anthropic
8+
prompt cache goes cold (>5 min), `assemble()` could return a context containing only
9+
the assistant greeting with no user turns. Providers that require conversations to end
10+
with a user message would then reject the LLM call, silently dropping the user's first
11+
real message.
12+
13+
`assemble()` now detects when the assembled context contains no user-role messages at
14+
all (raw-message-only DB state where every stored message is `assistant` or `toolResult`)
15+
and falls back to the live context, which correctly ends with the user's current message.
16+
Sessions with compaction summaries are unaffected because summaries are always stored
17+
with `role: "user"`.

src/engine.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5327,6 +5327,22 @@ export class LcmContextEngine implements ContextEngine {
53275327
};
53285328
}
53295329

5330+
// Guard: if assembled context contains no user turns at all (e.g. a new session
5331+
// that starts with an agent greeting before the first user message, cold-cache),
5332+
// fall back to live context to prevent LLM prefill errors. Summaries always
5333+
// have role "user", so this only fires for raw-message-only DB states where
5334+
// every stored message is role "assistant" or "toolResult".
5335+
const assembledHasUserTurn = assembled.messages.some((m) => m.role === "user");
5336+
if (!assembledHasUserTurn && params.messages.length > 0) {
5337+
this.deps.log.info(
5338+
`[lcm] assemble: assembled context has no user turns, falling back to live context to prevent prefill errors conversation=${conversation.conversationId} ${sessionLabel} assembledMessages=${assembled.messages.length} duration=${formatDurationMs(Date.now() - startedAt)}`,
5339+
);
5340+
return {
5341+
messages: params.messages,
5342+
estimatedTokens: 0,
5343+
};
5344+
}
5345+
53305346
this.deps.log.info(
53315347
`[lcm] assemble: done conversation=${conversation.conversationId} ${sessionLabel} contextItems=${contextItems.length} hasSummaryItems=${hasSummaryItems} inputMessages=${params.messages.length} outputMessages=${assembled.messages.length} tokenBudget=${tokenBudget} estimatedTokens=${assembled.estimatedTokens} duration=${formatDurationMs(Date.now() - startedAt)}`,
53325348
);

test/engine.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4144,6 +4144,68 @@ describe("LcmContextEngine.assemble canonical path", () => {
41444144
expect(result.estimatedTokens).toBe(0);
41454145
});
41464146

4147+
it("falls back to live context when assembled result has no user turns (cold-cache new session)", async () => {
4148+
// Reproduces the cold-cache new session scenario:
4149+
// Session starts with only an assistant greeting before any user message.
4150+
// When the cache goes cold and assemble() is called, the assembled DB context
4151+
// contains only the assistant greeting — no user turns. This would cause a
4152+
// prefill error on providers that require conversations to end with a user message.
4153+
// The guard should detect the missing user turns and fall back to live context.
4154+
const engine = createEngine();
4155+
const sessionId = "session-cold-cache-no-user-turns";
4156+
4157+
await engine.ingest({
4158+
sessionId,
4159+
message: { role: "assistant", content: "Hello! How can I help you today?" } as AgentMessage,
4160+
});
4161+
4162+
// Simulate the first real user message arriving (params.messages = current turn only)
4163+
const liveMessages: AgentMessage[] = [
4164+
{ role: "user", content: "Hi, I need help with something." },
4165+
] as AgentMessage[];
4166+
const result = await engine.assemble({
4167+
sessionId,
4168+
messages: liveMessages,
4169+
tokenBudget: 10_000,
4170+
});
4171+
4172+
// Should fall back to live context, not return the assistant-only DB context
4173+
expect(result.messages).toBe(liveMessages);
4174+
expect(result.estimatedTokens).toBe(0);
4175+
});
4176+
4177+
it("does not fall back when assembled result has user turns even if it ends with assistant", async () => {
4178+
// Normal session: DB has [user, assistant]. The assembled result ends with an
4179+
// assistant turn, but it contains user turns — this is valid because the framework
4180+
// appends the current user turn after the assembled context.
4181+
const engine = createEngine();
4182+
const sessionId = "session-ends-with-assistant-has-user-turns";
4183+
4184+
await engine.ingest({
4185+
sessionId,
4186+
message: { role: "user", content: "persisted message one" } as AgentMessage,
4187+
});
4188+
await engine.ingest({
4189+
sessionId,
4190+
message: { role: "assistant", content: "persisted message two" } as AgentMessage,
4191+
});
4192+
4193+
const liveMessages: AgentMessage[] = [
4194+
{ role: "user", content: "live turn" },
4195+
] as AgentMessage[];
4196+
const result = await engine.assemble({
4197+
sessionId,
4198+
messages: liveMessages,
4199+
tokenBudget: 10_000,
4200+
});
4201+
4202+
// Should use the DB context (has user turns), not fall back to live
4203+
expect(result.messages).not.toBe(liveMessages);
4204+
expect(result.messages).toHaveLength(2);
4205+
expect(result.messages[0].role).toBe("user");
4206+
expect(result.messages[1].role).toBe("assistant");
4207+
});
4208+
41474209
it("drops orphan tool results during assembled transcript repair", async () => {
41484210
const engine = createEngine();
41494211
const sessionId = "session-orphan-tool-result";

0 commit comments

Comments
 (0)