Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/chain-checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import type { WireProtocol } from "./util.js";
import { trailingSystemRunStart, type WireProtocol } from "./util.js";
import { normalizeGoogleUserPartOrder, type GooglePart } from "acp-kernel/wire";

// #1357/#1395/#1421 — Chain Checkpoint (first-processor-wins idempotent interop).
Expand Down Expand Up @@ -509,6 +509,13 @@ export function insertCheckpointCarrier(parsed: unknown, wire: WireProtocol, tag
return { ...body, messages: [...messages.slice(0, li), { ...m, content: merged }] };
}
}
// #5432: a body ending in a client trailing system run (legal — the
// kernel heal keeps it verbatim at array end) admits NO carrier slot:
// recognition only sees the TRAILING USER run (contract above), so a
// carrier inserted before the run would never be recognized again, and
// appending after the run is exactly the 400 shape (#5432 case 2).
// Skip the stamp — relay forwards unstamped on null (best-effort).
if (trailingSystemRunStart(messages) < messages.length) return null;
return { ...body, messages: [...messages, { role: "user", content: tag }] };
}
if (wire === "responses") {
Expand Down
6 changes: 4 additions & 2 deletions src/loop/adapter-anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CoreMessage } from "acp-kernel";
import { coreToAnthropic, healAnthropicSystemPositions, type AnthropicRequestBody } from "acp-kernel/wire";
import { coreToAnthropic, healAnthropicSystemPositions, insertBeforeTrailingSystemRun, type AnthropicRequestBody } from "acp-kernel/wire";
import { dedupeAdjacentOutputConfig } from "../dedupe-adjacent-output-config.js";
import { appendSystemText, declaredInclusiveFromUsage } from "../util.js";
import { stampAnthropicSystemCacheControl } from "./cache-control.js";
Expand Down Expand Up @@ -316,8 +316,10 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
// injectSystem — both must emit byte-identical system (F2 seam).
const system = originalSystem !== undefined ? appendSystemText(systemPrompt, originalSystem) : systemPrompt;
const stamped = cacheMarks ? stampAnthropicSystemCacheControl(system) : system;
// #5432: notes go BEFORE a trailing client system run; the #4570
// backstop heals whatever still arrives illegal (fold orphans).
const withNotes = notes && notes.length > 0
? healAnthropicSystemPositions([...messages, ...notes.map((text) => ({ role: "user" as const, content: text }))])
? healAnthropicSystemPositions(insertBeforeTrailingSystemRun(messages, notes.map((text) => ({ role: "user" as const, content: text }))))
: messages;
return { ...body, system: stamped, messages: withNotes };
},
Expand Down
21 changes: 12 additions & 9 deletions src/server/prepare-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ export async function prepareAnthropic(
const egressMessages = await applyImageRescalePass(processedMessages as BiliMessage[], { session, sessionId, log, maxDim: imageRescaleMaxDim(), toolArmed: imageRescaleToolArmed(session) });
rebuiltMessages = coreToAnthropic(egressMessages, anthropicCacheMarks ?? cacheControls);
if (sysNotes.length > 0) {
rebuiltMessages = [...rebuiltMessages, ...sysNotes.map((text) => ({ role: "user" as const, content: text }))];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, sysNotes.map((text) => ({ role: "user" as const, content: text })));
}

systemOut = injectSystem(parsed, opts, prompts, loopConfig, surface, visibilityMarkers, laneDecisions.compressSurfaceOff, laneDecisions.mcpDialect, detectGenerationBoundary(session, processedMessages, log));
Expand Down Expand Up @@ -523,7 +523,7 @@ export async function prepareAnthropic(
const rendered = renderNudgeText(turn.nudge, prompts, surface?.nudgeSections, "anthropic");
const renderedWithPayload = rendered.text;
if (rendered.text) {
rebuiltMessages = [...rebuiltMessages, { role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(renderedWithPayload, turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, [{ role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(renderedWithPayload, turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }]);
nudgeOutcome = "injected";
} else {
nudgeOutcome = "skipped";
Expand All @@ -536,15 +536,17 @@ export async function prepareAnthropic(
// [#5688] the side call shares the main request's upstream —
// un-deduped rebuiltMessages would 400 the decision too.
// [#4570] and the side array must obey the system-position rule
// on the array actually sent, which is exactly this one.
const sideBody: Record<string, unknown> = { ...parsed, messages: healAnthropicSystemPositions([...dedupeAdjacentOutputConfig(rebuiltMessages), { role: "user", content: buildDecisionPrompt(ranges) }]), system: systemOut, tools: toolsOut, stream: false, max_tokens: decide.maxTokens };
// on the array actually sent, which is exactly this one; #5432
// inserts the prompt BEFORE a trailing client system run so the
// backstop below stays a no-op for legal shapes.
const sideBody: Record<string, unknown> = { ...parsed, messages: healAnthropicSystemPositions(insertBeforeTrailingSystemRun(dedupeAdjacentOutputConfig(rebuiltMessages), [{ role: "user", content: buildDecisionPrompt(ranges) }])), system: systemOut, tools: toolsOut, stream: false, max_tokens: decide.maxTokens };
delete sideBody.prompt_cache_key;
stripDecisionReasoningFields("anthropic", sideBody);
const outcome = await runNudgeDecision({ req, opts, protocol: "anthropic", sideBody, session, log });
if (outcome.kind === "yes") {
const span = resolveDecisionRange(outcome, ranges);
if (span) {
rebuiltMessages = [...rebuiltMessages, { role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(buildDirectiveText(span.startRef, span.endRef, outcome.topic), turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, [{ role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(buildDirectiveText(span.startRef, span.endRef, outcome.topic), turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }]);
nudgeOutcome = "injected";
} else {
nudgeOutcome = "skipped";
Expand All @@ -560,7 +562,7 @@ export async function prepareAnthropic(
const rendered = renderNudgeText(turn.nudge, prompts, surface?.nudgeSections, "anthropic");
const renderedWithPayload = rendered.text;
if (rendered.text) {
rebuiltMessages = [...rebuiltMessages, { role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(renderedWithPayload, turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, [{ role: "user", content: withMarkerIntegrityNote(withSummaryBudgetNote(withFirstSightDrain(renderedWithPayload, turn.nudge.reason, externalSummaryEnabled(config)), externalSummaryEnabled(config)), visibilityMarkers) }]);
nudgeOutcome = "injected";
} else {
nudgeOutcome = "skipped";
Expand All @@ -580,16 +582,17 @@ export async function prepareAnthropic(
// carrier as the nudge (never persisted, never in the client's
// re-sent history, prefix-cache safe).
if (turn.statusLine) {
rebuiltMessages = [...rebuiltMessages, { role: "user", content: turn.statusLine }];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, [{ role: "user", content: turn.statusLine }]);
}
// [#1343/#1457] surface any earlier undelivered retrieve as an ephemeral
// trailing user note (kept last so it never reorders cached messages).
// trailing user note (after every stable message; only a client trailing
// system run may follow it — #5432).
// Snapshot WITHOUT consuming: forward() commits the ids only once
// upstream accepts this request; on failure they ride the next one.
const retrNotes = snapshotRetrievalNotes(session);
const retrNote = renderRetrievalNotes(retrNotes);
if (retrNote) {
rebuiltMessages = [...rebuiltMessages, { role: "user", content: retrNote }];
rebuiltMessages = insertBeforeTrailingSystemRun(rebuiltMessages, [{ role: "user", content: retrNote }]);
attachedRetrievalNoteIds = retrNotes.map((n) => n.id);
}
} catch (err) {
Expand Down
15 changes: 15 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,21 @@ export function systemToUser<T extends { role: string }>(messages: T[], should?:
);
}

// #5432: index of the first entry of the trailing run of role:"system" entries
// (== length when nothing trails). Shared by every host path that mutates an
// ALREADY assembled anthropic body: the kernel heal keeps a client's legal
// trailing system run verbatim at array end, so any post-assembly user insert
// must land BEFORE it or the request 400s ("role 'system' must precede an
// 'assistant' message or end the array").
export function trailingSystemRunStart(messages: readonly unknown[]): number {
let i = messages.length;
while (i > 0) {
const m = messages[i - 1];
if (!m || typeof m !== "object" || Array.isArray(m) || (m as Record<string, unknown>).role !== "system") break;
i--;
}
return i;
}

/** #719: Some OpenAI-compatible backends (DeepSeek) reject assistant messages
* whose `content` is null — they require a string content (possibly "") or
Expand Down
9 changes: 8 additions & 1 deletion src/wire-body.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { WireProtocol } from "./util.js";
import { trailingSystemRunStart, type WireProtocol } from "./util.js";
import { normalizeGoogleUserPartOrder, type GooglePart } from "acp-kernel/wire";

/** Append `text` as a trailing user turn on the wire's own request body.
Expand Down Expand Up @@ -62,6 +62,13 @@ export function appendTrailingUserText(protocol: WireProtocol, body: string | Bu
if (typeof c === "string") last.content = `${c}\n\n${text}`;
else if (Array.isArray(c)) last.content = [...c, { type: "text", text }];
else last.content = text;
} else if (protocol === "anthropic") {
// #5432: the assembled body can legally END in a client trailing system
// run (the kernel heal keeps it verbatim at array end); a plain push
// would put a user after it and 400. Insert before the run instead —
// identical to the push when no system trails. OpenAI has no such rule,
// so its bytes stay exactly as before.
arr.splice(trailingSystemRunStart(arr), 0, { role: "user", content: text });
} else {
arr.push({ role: "user", content: text });
}
Expand Down
Loading
Loading