fix(core): committed compaction attempts absorb the turn's pending input (#87)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbcc42427f
commit
787bb3d616
5 files changed
+492
-29
No files matched your search
@@ -105,6 +105,20 @@ export interface CompactionSettings {
|
|||||||
interface CompactionResult {
|
interface CompactionResult {
|
||||||
status: StopReason;
|
status: StopReason;
|
||||||
summary?: OmniMessage;
|
summary?: OmniMessage;
|
||||||
|
/**
|
||||||
|
* Whether at least one summarize attempt was **committed** by AgentHub (only a `completed`
|
||||||
|
* attempt commits — timeout/malformed end an incomplete stream and failed/auth/aborted throw
|
||||||
|
* or cut off before a clean end). The carry rule at every caller is a two-case binary on
|
||||||
|
* this flag (issue #85): committed → the input the caller folded in (mid-Task tool outputs,
|
||||||
|
* or the carry-over a manual `compact()` folds in) now lives in the old LLM object's history
|
||||||
|
* and must never be resent — strict providers reject the duplicates as stale tool_results;
|
||||||
|
* not committed → the folded input is untouched and is resent exactly as before. When
|
||||||
|
* nothing was folded in (idle/boundary compaction), the committed branch is vacuous —
|
||||||
|
* dropping zero outputs, clearing an empty carry — so no separate "was anything absorbed"
|
||||||
|
* signal is needed. Zero committed attempts also implies zero synthesized repairs (repairs
|
||||||
|
* only answer a committed rejection's tool calls).
|
||||||
|
*/
|
||||||
|
committed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -556,6 +570,9 @@ export class ContextEngine {
|
|||||||
// applies mid-Task — when runTurn returns, all of this turn's
|
// applies mid-Task — when runTurn returns, all of this turn's
|
||||||
// tool results are ready and paired with their tool_call.
|
// tool results are ready and paired with their tool_call.
|
||||||
const midTask = turn.toolOutputs.length > 0;
|
const midTask = turn.toolOutputs.length > 0;
|
||||||
|
// Outputs this turn still owes the model: dropped when a committed compaction attempt
|
||||||
|
// consumes them into history (the two-case carry rule below, issue #85).
|
||||||
|
let turnOutputs = turn.toolOutputs;
|
||||||
const compactionReason = this.compactionTrigger();
|
const compactionReason = this.compactionTrigger();
|
||||||
if (compactionReason) {
|
if (compactionReason) {
|
||||||
const mode = this.deps.compaction!.mode;
|
const mode = this.deps.compaction!.mode;
|
||||||
@@ -574,11 +591,20 @@ export class ContextEngine {
|
|||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
if (result.status === "aborted") {
|
if (result.status === "aborted") {
|
||||||
// User interrupted compaction: keep the original context; if mid-Task, hold the
|
// User interrupted compaction: keep the original context. The carry rule is the
|
||||||
// tool outputs as carry-over per case A. Abort is the one path that discards the
|
// same two-case binary as everywhere (issue #85): a committed attempt consumed
|
||||||
// steering queue (run's finally — control goes back to the user).
|
// this turn's outputs into history — only the repair stash summarizeContext left
|
||||||
|
// in pendingCarryOver still needs resending; otherwise the outputs are untouched
|
||||||
|
// and are appended behind the stash as case-A carry-over. Abort is the one path
|
||||||
|
// that discards the steering queue (run's finally — control goes back to the
|
||||||
|
// user).
|
||||||
if (midTask) {
|
if (midTask) {
|
||||||
this.pendingCarryOver = this.buildCarryOver(attemptInput, turn);
|
if (!result.committed) {
|
||||||
|
this.pendingCarryOver = [
|
||||||
|
...this.pendingCarryOver,
|
||||||
|
...this.buildCarryOver(attemptInput, turn),
|
||||||
|
];
|
||||||
|
}
|
||||||
yield* this.emitAbort("aborted during compaction");
|
yield* this.emitAbort("aborted during compaction");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -605,7 +631,13 @@ export class ContextEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// failed: keep the original context and Trace index; the current Task continues and
|
// failed: keep the original context and Trace index; the current Task continues and
|
||||||
// retries on the next trigger (no fallback to discard).
|
// retries on the next trigger (no fallback to discard). The carry rule (issue #85):
|
||||||
|
if (result.committed) {
|
||||||
|
// A committed attempt consumed this turn's outputs into history — drop them from
|
||||||
|
// the continuation; only the repair stash remains pending.
|
||||||
|
turnOutputs = [];
|
||||||
|
}
|
||||||
|
// else: nothing committed — the outputs are untouched and resent below as always.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,9 +647,18 @@ export class ContextEngine {
|
|||||||
// ending the Task — subject to the max-turns guard at the top of the loop).
|
// ending the Task — subject to the max-turns guard at the top of the loop).
|
||||||
const steering = yield* this.deliverSteering();
|
const steering = yield* this.deliverSteering();
|
||||||
// No tool_call this turn and no steering left -> the Task ends (the final reply has
|
// No tool_call this turn and no steering left -> the Task ends (the final reply has
|
||||||
// already been streamed out).
|
// already been streamed out). A compaction stash, if any, rides the next run.
|
||||||
if (!midTask && steering.length === 0) return;
|
if (!midTask && steering.length === 0) return;
|
||||||
nextInput = [...turn.toolOutputs, ...steering];
|
// Anything a failed compaction stashed mid-run (synthesized repair outputs from
|
||||||
|
// rejected attempts) rides the very next request, ahead of the turn outputs so
|
||||||
|
// tool_results stay contiguous and first.
|
||||||
|
const stashed = this.pendingCarryOver;
|
||||||
|
this.pendingCarryOver = [];
|
||||||
|
nextInput = [...stashed, ...turnOutputs, ...steering];
|
||||||
|
// Mid-task, but a committed compaction consumed the outputs and nothing else remains
|
||||||
|
// to send: the run ends here — the failure was surfaced via compaction_end(failed),
|
||||||
|
// the context is intact, and the next prompt continues from the committed state.
|
||||||
|
if (nextInput.length === 0) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,15 +711,27 @@ export class ContextEngine {
|
|||||||
yield* this.discardContext("manual");
|
yield* this.discardContext("manual");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = yield* this.summarizeContext(
|
// The carry seam is a clean binary on whether the compaction committed anything to
|
||||||
"manual",
|
// AgentHub (PR #87 review):
|
||||||
this.pendingCarryOver.map(downgradeCarriedGoalInput),
|
// - nothing committed (every attempt timeout/malformed/failed/auth/aborted): the fold
|
||||||
opts?.signal,
|
// never reached the model context — restore the prior carry-over **verbatim**. Zero
|
||||||
);
|
// committed attempts also means zero synthesized repairs, so there is no stash to
|
||||||
if (result.status === "completed") {
|
// interleave with (pinned by tests);
|
||||||
|
// - something committed: the carry-over is **consumed** — it lives in the committed
|
||||||
|
// history now and must never be resent; only the repair stash (unanswered tool_call
|
||||||
|
// pairing left by a final rejection, already in pendingCarryOver) remains pending.
|
||||||
|
// Dead-goal rounds are downgraded on the drained snapshot (goal mode's consumer-site
|
||||||
|
// rule): a no-commit restore keeps the downgraded copies — the downgrade is idempotent
|
||||||
|
// and every consumer applies it anyway, while non-goal messages keep their identity.
|
||||||
|
const folded = this.pendingCarryOver.map(downgradeCarriedGoalInput);
|
||||||
this.pendingCarryOver = [];
|
this.pendingCarryOver = [];
|
||||||
|
const result = yield* this.summarizeContext("manual", folded, opts?.signal);
|
||||||
|
if (result.status === "completed") {
|
||||||
this.pendingSummary = result.summary!;
|
this.pendingSummary = result.summary!;
|
||||||
|
} else if (!result.committed) {
|
||||||
|
this.pendingCarryOver = folded;
|
||||||
}
|
}
|
||||||
|
// committed but not completed: the carry-over is deliberately not restored.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1078,7 +1131,10 @@ export class ContextEngine {
|
|||||||
* then the compaction fails. timeout/malformed reconnect via the existing retry mechanism
|
* then the compaction fails. timeout/malformed reconnect via the existing retry mechanism
|
||||||
* under the compaction-specific cap (`compactionMaxReconnects`, tighter than the turn loop's
|
* under the compaction-specific cap (`compactionMaxReconnects`, tighter than the turn loop's
|
||||||
* ladder), collapsing to failed once retries are exhausted; on failure/abort, the original
|
* ladder), collapsing to failed once retries are exhausted; on failure/abort, the original
|
||||||
* context and Trace index are kept — it does not fall back to discard.
|
* context and Trace index are kept — it does not fall back to discard. The first **committed**
|
||||||
|
* attempt absorbs `pendingToolOutputs` into the old context's history (issue #85): later
|
||||||
|
* resends carry only the repairs and the Prompt, and the result's `committed` flag tells
|
||||||
|
* the caller the folded input must not be resent even though the compaction did not complete.
|
||||||
* Docs: /docs/agent-loop § "Compaction".
|
* Docs: /docs/agent-loop § "Compaction".
|
||||||
*/
|
*/
|
||||||
private async *summarizeContext(
|
private async *summarizeContext(
|
||||||
@@ -1095,10 +1151,18 @@ export class ContextEngine {
|
|||||||
// executed and aren't recorded again, while carry-over's not-yet-written synthetic content
|
// executed and aren't recorded again, while carry-over's not-yet-written synthetic content
|
||||||
// (flatten text, backfilled placeholders) and the compaction Prompt are written now.
|
// (flatten text, backfilled placeholders) and the compaction Prompt are written now.
|
||||||
const prompt = userText(settings.prompt);
|
const prompt = userText(settings.prompt);
|
||||||
const baseInput = [...pendingToolOutputs, prompt];
|
// The resend base: shrinks to the Prompt alone once an attempt commits — the folded turn
|
||||||
let input = baseInput;
|
// input then lives in the old LLM object's history, and resending it would make strict
|
||||||
|
// providers reject the request over duplicate/stale tool_results (issue #85).
|
||||||
|
let base = [...pendingToolOutputs, prompt];
|
||||||
|
let input = base;
|
||||||
await this.write(prompt);
|
await this.write(prompt);
|
||||||
|
|
||||||
|
// Whether any attempt was committed by AgentHub (only `completed` commits: timeout and
|
||||||
|
// malformed end an incomplete stream, and failed/auth/aborted throw or cut off before a
|
||||||
|
// clean end — none of those reach the stateful commit). Returned as `committed`: the
|
||||||
|
// callers' two-case carry rule branches on it.
|
||||||
|
let committed = false;
|
||||||
// Synthesized outputs answering the latest rejected attempt's tool calls, not yet carried
|
// Synthesized outputs answering the latest rejected attempt's tool calls, not yet carried
|
||||||
// by a committed request: prepended to the retry input, and stashed as carry-over should
|
// by a committed request: prepended to the retry input, and stashed as carry-over should
|
||||||
// the compaction be abandoned first (see stashRepairs).
|
// the compaction be abandoned first (see stashRepairs).
|
||||||
@@ -1114,13 +1178,16 @@ export class ContextEngine {
|
|||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
||||||
return { status: "aborted" };
|
return { status: "aborted", committed };
|
||||||
}
|
}
|
||||||
const attempt = await this.runCompactionRequest(input, signal, reconnects);
|
const attempt = await this.runCompactionRequest(input, signal, reconnects);
|
||||||
if (attempt.status === "completed") {
|
if (attempt.status === "completed") {
|
||||||
// The attempt was committed by AgentHub, so whatever its input carried — including
|
// The attempt was committed by AgentHub, so whatever its input carried — including
|
||||||
// repairs synthesized for a previous rejection — is now in history and must not be
|
// repairs synthesized for a previous rejection — is now in history and must not be
|
||||||
// resent.
|
// resent. The first commit absorbs the folded turn input: the base shrinks to the
|
||||||
|
// Prompt alone.
|
||||||
|
committed = true;
|
||||||
|
base = [prompt];
|
||||||
pendingRepairs = [];
|
pendingRepairs = [];
|
||||||
// A completed response counts as a compaction success only when it is a **usable
|
// A completed response counts as a compaction success only when it is a **usable
|
||||||
// summary**: the extracted text is non-empty and the response called no tool. The
|
// summary**: the extracted text is non-empty and the response called no tool. The
|
||||||
@@ -1143,7 +1210,7 @@ export class ContextEngine {
|
|||||||
const summary = userText(buildContextSummaryText(summaryText));
|
const summary = userText(buildContextSummaryText(summaryText));
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "completed");
|
yield* this.emitCompactionEnd(reason, "summarize", "completed");
|
||||||
await this.startNewContext();
|
await this.startNewContext();
|
||||||
return { status: "completed", summary };
|
return { status: "completed", summary, committed };
|
||||||
}
|
}
|
||||||
// Rejected. Tool calls were never dispatched, yet the assistant turn holding them IS
|
// Rejected. Tool calls were never dispatched, yet the assistant turn holding them IS
|
||||||
// committed on the live LLM object — leaving them unanswered would get every
|
// committed on the live LLM object — leaving them unanswered would get every
|
||||||
@@ -1163,13 +1230,14 @@ export class ContextEngine {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const repair of pendingRepairs) await this.write(repair);
|
for (const repair of pendingRepairs) await this.write(repair);
|
||||||
// Rebuild from baseInput rather than appending: everything the rejected attempt's
|
// Rebuild from the (shrunken) base rather than appending: everything the rejected
|
||||||
// input carried is committed, so only the fresh repairs and the Prompt go out again.
|
// attempt's input carried is committed, so only the fresh repairs and the Prompt go
|
||||||
input = pendingRepairs.length > 0 ? [...pendingRepairs, ...baseInput] : baseInput;
|
// out again.
|
||||||
|
input = pendingRepairs.length > 0 ? [...pendingRepairs, ...base] : base;
|
||||||
if (rejections >= MAX_SUMMARY_REJECTIONS) {
|
if (rejections >= MAX_SUMMARY_REJECTIONS) {
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
||||||
return { status: "failed" };
|
return { status: "failed", committed };
|
||||||
}
|
}
|
||||||
// A rejection is model behavior, not a transport failure: the request pipeline is
|
// A rejection is model behavior, not a transport failure: the request pipeline is
|
||||||
// healthy, so the repaired input is resent immediately — no backoff and no
|
// healthy, so the repaired input is resent immediately — no backoff and no
|
||||||
@@ -1181,7 +1249,7 @@ export class ContextEngine {
|
|||||||
if (attempt.status === "aborted") {
|
if (attempt.status === "aborted") {
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
||||||
return { status: "aborted" };
|
return { status: "aborted", committed };
|
||||||
}
|
}
|
||||||
if (attempt.status === "failed" || attempt.status === "auth") {
|
if (attempt.status === "failed" || attempt.status === "auth") {
|
||||||
// `auth` folds into `failed` here: the compaction event pair keeps its
|
// `auth` folds into `failed` here: the compaction event pair keeps its
|
||||||
@@ -1190,7 +1258,7 @@ export class ContextEngine {
|
|||||||
// request will surface it; the compaction request_end is Trace-only).
|
// request will surface it; the compaction request_end is Trace-only).
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
||||||
return { status: "failed" };
|
return { status: "failed", committed };
|
||||||
}
|
}
|
||||||
// timeout / malformed: retried via reconnect — transport-level, never committed by
|
// timeout / malformed: retried via reconnect — transport-level, never committed by
|
||||||
// AgentHub (case B), so the input (any pending repairs included) is resent unchanged.
|
// AgentHub (case B), so the input (any pending repairs included) is resent unchanged.
|
||||||
@@ -1200,14 +1268,14 @@ export class ContextEngine {
|
|||||||
if (reconnects >= this.compactionMaxReconnects) {
|
if (reconnects >= this.compactionMaxReconnects) {
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
yield* this.emitCompactionEnd(reason, "summarize", "failed");
|
||||||
return { status: "failed" };
|
return { status: "failed", committed };
|
||||||
}
|
}
|
||||||
reconnects += 1;
|
reconnects += 1;
|
||||||
const ok = await this.backoff(reconnects, signal);
|
const ok = await this.backoff(reconnects, signal);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
this.stashRepairs(pendingRepairs);
|
this.stashRepairs(pendingRepairs);
|
||||||
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
yield* this.emitCompactionEnd(reason, "summarize", "aborted");
|
||||||
return { status: "aborted" };
|
return { status: "aborted", committed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -819,6 +819,209 @@ describe("context compaction", () => {
|
|||||||
expect(closureRepairs).toEqual([]);
|
expect(closureRepairs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mid-task: a committed rejection absorbs the turn's tool outputs — retries never resend them, and an empty-handed abandonment ends the run", async () => {
|
||||||
|
// Issue #85, strict-provider scenario: the first committed attempt puts the turn's tool
|
||||||
|
// outputs (folded into the compaction input) into the old LLM object's history. From then
|
||||||
|
// on the retries must carry only the repairs and the Prompt — resending the outputs would
|
||||||
|
// be rejected as stale tool_results — and after the compaction is abandoned the
|
||||||
|
// continuation must not resend them either. Here the final rejection is empty (no repairs
|
||||||
|
// left to deliver) and no steering is queued, so the run ends at the failure: the context
|
||||||
|
// is intact and the next prompt continues from the committed state.
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
// Turn 1: a tool call, over the threshold -> mid-task compaction.
|
||||||
|
{
|
||||||
|
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "ct" }), usage(150, 150)],
|
||||||
|
},
|
||||||
|
// Attempt 1: committed but rejected (tool call) -> absorbs [output(ct), prompt].
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
assistantText("[summary]nope[/summary]"),
|
||||||
|
toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Attempts 2-5: committed but empty -> rejected; the base has shrunk to the Prompt.
|
||||||
|
{ messages: [thinkingMessage("blank 2")] },
|
||||||
|
{ messages: [thinkingMessage("blank 3")] },
|
||||||
|
{ messages: [thinkingMessage("blank 4")] },
|
||||||
|
{ messages: [thinkingMessage("blank 5")] },
|
||||||
|
// Next run after the abandonment: served by the same instance, plain prompt.
|
||||||
|
{ messages: [assistantText("continuing"), usage(60, 900)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
let created = 0;
|
||||||
|
const trace = new Writer({ tracesDir: traces, sessionId: "sess_absorb" });
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
trace,
|
||||||
|
sessionMeta: metaMessage,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => {
|
||||||
|
created += 1;
|
||||||
|
return new ScriptedLLM([], "llm2");
|
||||||
|
},
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
const oldPath = trace.currentPath();
|
||||||
|
|
||||||
|
const out1 = await collect(engine.run([userText("task one")], { approve: allowAll }));
|
||||||
|
|
||||||
|
expect(compactionEvents(out1)[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||||
|
// The run ends at the empty-handed abandonment without an abort: the compaction failure
|
||||||
|
// is the whole story.
|
||||||
|
expect(payloadTypes(out1)).not.toContain("abort");
|
||||||
|
expect(llm1.calls).toHaveLength(6);
|
||||||
|
// Attempt 1 folds the turn's tool output in; attempt 2 carries the repair + Prompt but
|
||||||
|
// NOT the absorbed output; attempts 3-5 are Prompt-only.
|
||||||
|
expect(payloadTypes(llm1.calls[1]!)).toEqual(["tool_call_output", "text"]);
|
||||||
|
expect((llm1.calls[1]![0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("ct");
|
||||||
|
expect(payloadTypes(llm1.calls[2]!)).toEqual(["tool_call_output", "text"]);
|
||||||
|
expect((llm1.calls[2]![0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("c1");
|
||||||
|
expect(textOf(llm1.calls[2]![1]!)).toBe("COMPACT NOW");
|
||||||
|
for (let attempt = 3; attempt <= 5; attempt += 1) {
|
||||||
|
expect(llm1.calls[attempt]!.map(textOf)).toEqual(["COMPACT NOW"]);
|
||||||
|
}
|
||||||
|
// Original context kept: no LLM swap, no Trace rotation.
|
||||||
|
expect(created).toBe(0);
|
||||||
|
expect(trace.currentPath()).toBe(oldPath);
|
||||||
|
|
||||||
|
// The next run continues from the committed state: the absorbed outputs are not resent
|
||||||
|
// and nothing was stashed (the final rejection was empty).
|
||||||
|
await collect(engine.run([userText("task two")], { approve: allowAll }));
|
||||||
|
expect(llm1.calls[6]!.map(textOf)).toEqual(["task two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mid-task: abandonment with a trailing tool-calling rejection continues the task with the repair alone", async () => {
|
||||||
|
// Any committed attempt absorbs — here the FIRST (empty) rejection commits the turn
|
||||||
|
// outputs, and the FINAL rejection leaves unanswered tool calls. The continuation after
|
||||||
|
// the failure delivers exactly the stashed repairs (never the absorbed outputs), keeping
|
||||||
|
// the live object's history well-formed while the task keeps running.
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "ct" }), usage(150, 150)],
|
||||||
|
},
|
||||||
|
// Attempts 1-4: empty rejections (attempt 1 commits and absorbs the outputs).
|
||||||
|
{ messages: [thinkingMessage("blank 1")] },
|
||||||
|
{ messages: [thinkingMessage("blank 2")] },
|
||||||
|
{ messages: [thinkingMessage("blank 3")] },
|
||||||
|
{ messages: [thinkingMessage("blank 4")] },
|
||||||
|
// Attempt 5: rejected with a tool call -> its repair is stashed at the abandonment.
|
||||||
|
{ messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c5" })] },
|
||||||
|
// Continuation: the repair alone; the model wraps the task up (under the threshold).
|
||||||
|
{ messages: [assistantText("recovered"), usage(60, 900)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => new ScriptedLLM([], "llm2"),
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||||
|
expect(compactionEvents(out)[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||||
|
expect(llm1.calls).toHaveLength(7);
|
||||||
|
// Attempt 1 folds the outputs; attempts 2-5 are Prompt-only (absorbed by the first commit).
|
||||||
|
expect(payloadTypes(llm1.calls[1]!)).toEqual(["tool_call_output", "text"]);
|
||||||
|
for (let attempt = 2; attempt <= 5; attempt += 1) {
|
||||||
|
expect(llm1.calls[attempt]!.map(textOf)).toEqual(["COMPACT NOW"]);
|
||||||
|
}
|
||||||
|
// The continuation input is the repair answering c5 — nothing else: no absorbed outputs,
|
||||||
|
// no prompt.
|
||||||
|
expect(payloadTypes(llm1.calls[6]!)).toEqual(["tool_call_output"]);
|
||||||
|
const cont = llm1.calls[6]![0]!.payload as { tool_call_id: string; output: string };
|
||||||
|
expect(cont.tool_call_id).toBe("c5");
|
||||||
|
expect(cont.output).toBe(
|
||||||
|
"[tool error] the compaction request expects a summary, not tool calls",
|
||||||
|
);
|
||||||
|
// The task finished on the continuation; the stash is spent for later runs.
|
||||||
|
expect(out.map((m) => (m.payload as { text?: string }).text)).toContain("recovered");
|
||||||
|
await collect(engine.run([userText("again")], { approve: allowAll }));
|
||||||
|
expect(llm1.calls[7]!.map(textOf)).toEqual(["again"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mid-task: an all-transport abandonment absorbs nothing — the outputs are resent as before", async () => {
|
||||||
|
// Counter-case: timeout/malformed attempts never commit, so the turn's tool outputs were
|
||||||
|
// never absorbed and the post-failure continuation must resend them exactly as before
|
||||||
|
// (their tool_use pairing is still unanswered on the live object).
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "ct" }), usage(150, 150)],
|
||||||
|
},
|
||||||
|
{ messages: [], outcome: { status: "timeout" } },
|
||||||
|
{ messages: [], outcome: { status: "timeout" } },
|
||||||
|
// Continuation: the resent tool output; the model wraps up under the threshold.
|
||||||
|
{ messages: [assistantText("done on old context"), usage(60, 400)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => new ScriptedLLM([], "llm2"),
|
||||||
|
compactionMaxReconnects: 1,
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||||
|
expect(compactionEvents(out)[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||||
|
expect(llm1.calls).toHaveLength(4);
|
||||||
|
expect(payloadTypes(llm1.calls[3]!)).toEqual(["tool_call_output"]);
|
||||||
|
expect((llm1.calls[3]![0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("ct");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mid-task: an abort after a committed rejection merges the stash — repairs ride, absorbed outputs do not", async () => {
|
||||||
|
// The abort-in-window case (issue #85): the stash must merge, not be overwritten. After a
|
||||||
|
// committed rejection the turn outputs are absorbed, so the case-A carry-over is skipped
|
||||||
|
// entirely and only the unanswered repair rides the next run.
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "ct" }), usage(150, 150)],
|
||||||
|
},
|
||||||
|
// Attempt 1: committed rejection with a tool call -> absorbs outputs, repair pending.
|
||||||
|
{ messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c1" })] },
|
||||||
|
// Attempt 2 (carrying the repair): the user aborts mid-request -> uncommitted, the
|
||||||
|
// repair is still unanswered and gets stashed.
|
||||||
|
{ messages: [], outcome: { status: "aborted" } },
|
||||||
|
// Next run: the stash leads, then the new prompt.
|
||||||
|
{ messages: [assistantText("back"), usage(60, 500)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => new ScriptedLLM([], "llm2"),
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await collect(engine.run([userText("go")], { approve: allowAll }));
|
||||||
|
const events = compactionEvents(out);
|
||||||
|
expect(events[1]).toMatchObject({ type: "compaction_end", status: "aborted" });
|
||||||
|
expect(payloadTypes(out)).toContain("abort");
|
||||||
|
expect(llm1.calls).toHaveLength(3);
|
||||||
|
// Attempt 2 carried the repair + Prompt (not the absorbed outputs).
|
||||||
|
expect(payloadTypes(llm1.calls[2]!)).toEqual(["tool_call_output", "text"]);
|
||||||
|
expect((llm1.calls[2]![0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("c1");
|
||||||
|
|
||||||
|
// The next run leads with the still-unanswered repair; the absorbed turn outputs are
|
||||||
|
// NOT re-held as carry-over (no duplicate ct output).
|
||||||
|
await collect(engine.run([userText("next")], { approve: allowAll }));
|
||||||
|
const nextRun = llm1.calls[3]!;
|
||||||
|
expect(payloadTypes(nextRun)).toEqual(["tool_call_output", "text"]);
|
||||||
|
expect((nextRun[0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("c1");
|
||||||
|
expect(textOf(nextRun[1]!)).toBe("next");
|
||||||
|
});
|
||||||
|
|
||||||
it("the compaction request carries exactly the same tools as ordinary requests (prompt-cache pin)", async () => {
|
it("the compaction request carries exactly the same tools as ordinary requests (prompt-cache pin)", async () => {
|
||||||
// Owner constraint (#84): compaction runs exactly when the context is at its largest, and
|
// Owner constraint (#84): compaction runs exactly when the context is at its largest, and
|
||||||
// the provider's prompt cache only holds if the request prefix — the tool list included —
|
// the provider's prompt cache only holds if the request prefix — the tool list included —
|
||||||
@@ -1048,6 +1251,104 @@ describe("context compaction", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("manual compaction that commits nothing restores the prior carry-over verbatim", async () => {
|
||||||
|
// The carry seam's binary (PR #87 review): every attempt was a transport failure, so
|
||||||
|
// nothing reached AgentHub — the folded carry-over comes back exactly as it was (the very
|
||||||
|
// same message objects, not routed through any absorption logic), and with zero committed
|
||||||
|
// attempts there are no repairs to interleave with.
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
{ messages: [assistantText("hi"), usage(10, 10)] },
|
||||||
|
// Manual compaction attempts: transport failures only — nothing committed.
|
||||||
|
{ messages: [], outcome: { status: "timeout" } },
|
||||||
|
{ messages: [], outcome: { status: "timeout" } },
|
||||||
|
// Run 3: the restored carry-over leads the input, exactly as before the compact().
|
||||||
|
{ messages: [assistantText("resumed"), usage(20, 40)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => new ScriptedLLM([], "llm2"),
|
||||||
|
compactionMaxReconnects: 1,
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
await collect(engine.run([userText("start")], { approve: allowAll }));
|
||||||
|
// An aborted-before-issue run leaves its input as carry-over.
|
||||||
|
const aborted = new AbortController();
|
||||||
|
aborted.abort();
|
||||||
|
const pendingMsg = userText("pending question");
|
||||||
|
await collect(engine.run([pendingMsg], { signal: aborted.signal, approve: allowAll }));
|
||||||
|
expect(llm1.calls).toHaveLength(1); // the aborted run never reached the LLM
|
||||||
|
|
||||||
|
const out = await collect(engine.compact());
|
||||||
|
expect(compactionEvents(out)[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||||
|
// The compaction folded the carry-over into its (uncommitted) attempts...
|
||||||
|
expect(llm1.calls).toHaveLength(3);
|
||||||
|
expect(llm1.calls[1]!.map(textOf)).toEqual(["pending question", "COMPACT NOW"]);
|
||||||
|
expect(llm1.calls[2]!.map(textOf)).toEqual(["pending question", "COMPACT NOW"]);
|
||||||
|
|
||||||
|
// ...and restored it verbatim: the next run resends the very same message object first.
|
||||||
|
await collect(engine.run([userText("run3")], { approve: allowAll }));
|
||||||
|
expect(llm1.calls[3]![0]).toBe(pendingMsg);
|
||||||
|
expect(llm1.calls[3]!.map(textOf)).toEqual(["pending question", "run3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual compaction with a committed attempt consumes the carry-over; only repairs remain pending", async () => {
|
||||||
|
// The other side of the binary: the first committed attempt put the folded carry-over
|
||||||
|
// into AgentHub history, so it is disposed of at the seam — never restored — and the next
|
||||||
|
// input reflects the committed state plus the repair stash left by the final rejection.
|
||||||
|
const llm1 = new ScriptedLLM(
|
||||||
|
[
|
||||||
|
{ messages: [assistantText("hi"), usage(10, 10)] },
|
||||||
|
// Attempt 1: committed but empty -> absorbs the folded carry-over into history.
|
||||||
|
{ messages: [thinkingMessage("blank 1")] },
|
||||||
|
// Attempts 2-4: still empty; the base has shrunk to the Prompt.
|
||||||
|
{ messages: [thinkingMessage("blank 2")] },
|
||||||
|
{ messages: [thinkingMessage("blank 3")] },
|
||||||
|
{ messages: [thinkingMessage("blank 4")] },
|
||||||
|
// Attempt 5: rejected with a tool call -> its repair is stashed at the abandonment.
|
||||||
|
{ messages: [toolCall({ name: "t", arguments: "{}", toolCallId: "c9" })] },
|
||||||
|
// Run 3: the repair leads; the consumed carry-over is NOT resent.
|
||||||
|
{ messages: [assistantText("resumed"), usage(20, 40)] },
|
||||||
|
],
|
||||||
|
"llm1",
|
||||||
|
);
|
||||||
|
const engine = new ContextEngine({
|
||||||
|
llm: llm1,
|
||||||
|
environment: fakeEnvironment,
|
||||||
|
compaction: settings(),
|
||||||
|
createLLM: () => new ScriptedLLM([], "llm2"),
|
||||||
|
reconnectBackoffMs: 1,
|
||||||
|
});
|
||||||
|
await collect(engine.run([userText("start")], { approve: allowAll }));
|
||||||
|
const aborted = new AbortController();
|
||||||
|
aborted.abort();
|
||||||
|
await collect(
|
||||||
|
engine.run([userText("pending question")], { signal: aborted.signal, approve: allowAll }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = await collect(engine.compact());
|
||||||
|
expect(compactionEvents(out)[1]).toMatchObject({ type: "compaction_end", status: "failed" });
|
||||||
|
expect(llm1.calls).toHaveLength(6);
|
||||||
|
// Attempt 1 folded the carry-over in (and committed it); later attempts resend the Prompt
|
||||||
|
// alone — the absorbed carry-over never again.
|
||||||
|
expect(llm1.calls[1]!.map(textOf)).toEqual(["pending question", "COMPACT NOW"]);
|
||||||
|
for (let attempt = 2; attempt <= 5; attempt += 1) {
|
||||||
|
expect(llm1.calls[attempt]!.map(textOf)).toEqual(["COMPACT NOW"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The carry-over is consumed at the seam — the next run leads with the stashed repair
|
||||||
|
// alone, continuing from committed history.
|
||||||
|
await collect(engine.run([userText("run3")], { approve: allowAll }));
|
||||||
|
const run3 = llm1.calls[6]!;
|
||||||
|
expect(payloadTypes(run3)).toEqual(["tool_call_output", "text"]);
|
||||||
|
expect((run3[0]!.payload as { tool_call_id?: string }).tool_call_id).toBe("c9");
|
||||||
|
expect(textOf(run3[1]!)).toBe("run3");
|
||||||
|
});
|
||||||
|
|
||||||
it("no compaction capability (createLLM missing) means thresholds never fire and compact() is a no-op", async () => {
|
it("no compaction capability (createLLM missing) means thresholds never fire and compact() is a no-op", async () => {
|
||||||
const llm1 = new ScriptedLLM(
|
const llm1 = new ScriptedLLM(
|
||||||
[{ messages: [assistantText("big"), usage(999999, 999999)] }],
|
[{ messages: [assistantText("big"), usage(999999, 999999)] }],
|
||||||
|
|||||||
@@ -566,4 +566,98 @@ describe("resumeTrace regressions (PR #39 review)", () => {
|
|||||||
expect(result.sessionTurns).toBe(1);
|
expect(result.sessionTurns).toBe(1);
|
||||||
expect(result.carryOver).toEqual([]);
|
expect(result.carryOver).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("absorbed mid-task compaction (committed rejections) replays with nothing left to resend", () => {
|
||||||
|
// Resume symmetry for issue #85: a mid-task compaction whose first attempt committed has
|
||||||
|
// absorbed the turn's tool output into history; the repair answering the rejected
|
||||||
|
// attempt's tool call rode the second committed attempt. After a process exit at the
|
||||||
|
// abandonment, replay must reconstruct exactly the live engine's state: everything in
|
||||||
|
// history, nothing in carry-over — the next request continues from the committed state
|
||||||
|
// without resending any tool_result.
|
||||||
|
const result = resumeTrace([
|
||||||
|
meta(),
|
||||||
|
userText("go"),
|
||||||
|
requestBegin(),
|
||||||
|
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "ct" }),
|
||||||
|
requestEnd("completed"),
|
||||||
|
tokenUsage(usage(150), usage(150)),
|
||||||
|
toolCallOutput({ output: "tool ran", toolCallId: "ct" }),
|
||||||
|
compactionBegin({ reason: "context", mode: "summarize", context: 150, turns: 1 }),
|
||||||
|
userText("COMPACT NOW"),
|
||||||
|
requestBegin(),
|
||||||
|
assistantText("[summary]nope[/summary]"),
|
||||||
|
toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }),
|
||||||
|
requestEnd("completed"),
|
||||||
|
tokenUsage(usage(160), usage(310)),
|
||||||
|
toolCallOutput({
|
||||||
|
output: "[tool error] the compaction request expects a summary, not tool calls",
|
||||||
|
toolCallId: "c1",
|
||||||
|
stopReason: "failed",
|
||||||
|
}),
|
||||||
|
requestBegin(),
|
||||||
|
thinkingMessage("still nothing"),
|
||||||
|
requestEnd("completed"),
|
||||||
|
tokenUsage(usage(170), usage(480)),
|
||||||
|
compactionEnd({ reason: "context", mode: "summarize", status: "failed" }),
|
||||||
|
]);
|
||||||
|
expect(result.contextClosed).toBe(false);
|
||||||
|
expect(result.pendingSummary).toBeUndefined();
|
||||||
|
// Both committed compaction rounds enter history: the turn output was absorbed by the
|
||||||
|
// first, the repair by the second.
|
||||||
|
expect(result.history.map((m) => (m.payload as { type?: string }).type)).toEqual([
|
||||||
|
"text",
|
||||||
|
"tool_call",
|
||||||
|
"tool_call_output",
|
||||||
|
"text",
|
||||||
|
"text",
|
||||||
|
"tool_call",
|
||||||
|
"tool_call_output",
|
||||||
|
"thinking",
|
||||||
|
]);
|
||||||
|
// Nothing left to resend — matching the live engine, which also holds no carry-over here.
|
||||||
|
expect(result.carryOver).toEqual([]);
|
||||||
|
expect(result.sessionTurns).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbed mid-task compaction abandoned with an unanswered repair replays it as carry-over", () => {
|
||||||
|
// The abort-in-window shape: the rejected attempt committed (absorbing the turn output),
|
||||||
|
// its repair was written, and the process exited before any request carried the repair.
|
||||||
|
// Replay's eligibility filter keeps exactly the repair (paired with the committed,
|
||||||
|
// unanswered c1) as carry-over — the same stash the live engine holds — and does not
|
||||||
|
// reclaim the absorbed turn output.
|
||||||
|
const result = resumeTrace([
|
||||||
|
meta(),
|
||||||
|
userText("go"),
|
||||||
|
requestBegin(),
|
||||||
|
toolCall({ name: "exec_command", arguments: "{}", toolCallId: "ct" }),
|
||||||
|
requestEnd("completed"),
|
||||||
|
tokenUsage(usage(150), usage(150)),
|
||||||
|
toolCallOutput({ output: "tool ran", toolCallId: "ct" }),
|
||||||
|
compactionBegin({ reason: "context", mode: "summarize", context: 150, turns: 1 }),
|
||||||
|
userText("COMPACT NOW"),
|
||||||
|
requestBegin(),
|
||||||
|
toolCall({ name: "t", arguments: "{}", toolCallId: "c1" }),
|
||||||
|
requestEnd("completed"),
|
||||||
|
tokenUsage(usage(160), usage(310)),
|
||||||
|
toolCallOutput({
|
||||||
|
output: "[tool error] the compaction request expects a summary, not tool calls",
|
||||||
|
toolCallId: "c1",
|
||||||
|
stopReason: "failed",
|
||||||
|
}),
|
||||||
|
compactionEnd({ reason: "context", mode: "summarize", status: "aborted" }),
|
||||||
|
]);
|
||||||
|
expect(result.contextClosed).toBe(false);
|
||||||
|
// The absorbed turn output sits in history (attempt 1's committed input), not carry-over.
|
||||||
|
expect(result.history.map((m) => (m.payload as { type?: string }).type)).toEqual([
|
||||||
|
"text",
|
||||||
|
"tool_call",
|
||||||
|
"tool_call_output",
|
||||||
|
"text",
|
||||||
|
"tool_call",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.carryOver.map((m) => (m.payload as { tool_call_id?: string }).tool_call_id),
|
||||||
|
).toEqual(["c1"]);
|
||||||
|
expect((result.carryOver[0]!.payload as { output?: string }).output).toContain("[tool error]");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ Three triggers (`compaction_begin.reason`):
|
|||||||
|
|
||||||
Two modes: `summarize` (default) appends the compaction Prompt to the old context, extracts the `[summary]`, wraps it as a `[context_summary]` user text and continues in a **fresh model context**; `discard` simply drops the old context. System markers are written as `[tag]…[/tag]`; the earlier angle-bracket form (`<summary>`, `<context_summary>`, …) is still recognized when reading old Traces and old persisted compaction prompts. Compaction rotates the [Trace file](/sessions-and-traces) (`_002`, `_003`, …) — one Trace file always equals one complete model context. `compactability()` probes feasibility before `session.compact()` (`ok | unsupported | empty | just_compacted`).
|
Two modes: `summarize` (default) appends the compaction Prompt to the old context, extracts the `[summary]`, wraps it as a `[context_summary]` user text and continues in a **fresh model context**; `discard` simply drops the old context. System markers are written as `[tag]…[/tag]`; the earlier angle-bracket form (`<summary>`, `<context_summary>`, …) is still recognized when reading old Traces and old persisted compaction prompts. Compaction rotates the [Trace file](/sessions-and-traces) (`_002`, `_003`, …) — one Trace file always equals one complete model context. `compactability()` probes feasibility before `session.compact()` (`ok | unsupported | empty | just_compacted`).
|
||||||
|
|
||||||
The compaction request keeps the session's toolset **unchanged** — the request prefix (tool list included) stays byte-identical to ordinary turns, so the provider's prompt cache remains valid at the moment the context is largest. Compaction still succeeds only with a valid summary: a response that calls a tool or whose extracted summary is empty is rejected — any tool calls are answered with synthesized failed outputs (keeping `tool_use`/`tool_result` pairing intact) and the repaired request is resent immediately (a rejection is model behavior, not a transport failure, so no backoff applies), up to 5 rejected attempts; then the compaction ends `failed`, keeping the original context and Trace file until the next trigger. Transport `timeout`/`malformed` attempts follow the compaction-specific reconnect cap and backoff ladder described under "Automatic reconnect" above.
|
The compaction request keeps the session's toolset **unchanged** — the request prefix (tool list included) stays byte-identical to ordinary turns, so the provider's prompt cache remains valid at the moment the context is largest. Compaction still succeeds only with a valid summary: a response that calls a tool or whose extracted summary is empty is rejected — any tool calls are answered with synthesized failed outputs (keeping `tool_use`/`tool_result` pairing intact) and the repaired request is resent immediately (a rejection is model behavior, not a transport failure, so no backoff applies), up to 5 rejected attempts; then the compaction ends `failed`, keeping the original context and Trace file until the next trigger. Transport `timeout`/`malformed` attempts follow the compaction-specific reconnect cap and backoff ladder described under "Automatic reconnect" above. The first **committed** attempt — adopted or rejected — also absorbs whatever turn input was folded into the compaction request (mid-task tool results, or the carry-over a manual `/compact` folds in) into the old context's history: retries resend only the repairs and the Prompt, and the continuation after an abandoned compaction never resends the absorbed input.
|
||||||
|
|
||||||
## Concurrency model
|
## Concurrency model
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ interface CompactionSettings {
|
|||||||
|
|
||||||
两种模式:`summarize`(默认)向旧上下文追加压缩 Prompt,提取 `[summary]` 后包装为 `[context_summary]` 用户文本,在**全新的模型上下文**中继续;`discard` 直接丢弃旧上下文。系统标记统一写作 `[tag]…[/tag]`;读取旧 Trace 与旧压缩 Prompt 时仍识别早期的尖括号形式(`<summary>`、`<context_summary>` 等)。压缩时 [Trace 文件随之轮转](/sessions-and-traces)(`_002`、`_003`……),一个 Trace 文件恒等于一个完整模型上下文。`session.compact()` 前可用 `compactability()` 探询可行性(`ok | unsupported | empty | just_compacted`)。
|
两种模式:`summarize`(默认)向旧上下文追加压缩 Prompt,提取 `[summary]` 后包装为 `[context_summary]` 用户文本,在**全新的模型上下文**中继续;`discard` 直接丢弃旧上下文。系统标记统一写作 `[tag]…[/tag]`;读取旧 Trace 与旧压缩 Prompt 时仍识别早期的尖括号形式(`<summary>`、`<context_summary>` 等)。压缩时 [Trace 文件随之轮转](/sessions-and-traces)(`_002`、`_003`……),一个 Trace 文件恒等于一个完整模型上下文。`session.compact()` 前可用 `compactability()` 探询可行性(`ok | unsupported | empty | just_compacted`)。
|
||||||
|
|
||||||
压缩请求**保持会话工具集不变**——请求前缀(含工具列表)与普通轮次逐字节一致,确保上下文最大的时刻提供商的提示词缓存依然有效。只有得到有效摘要,压缩才算成功:若响应中出现工具调用、或提取出的摘要为空,则判为无效并重试——工具调用会先以合成的失败输出逐一应答(保持 `tool_use`/`tool_result` 配对完整),修复后的请求**立即重发**(无效摘要是模型行为而非传输故障,不做退避),最多允许 5 次无效尝试,之后压缩以 `failed` 结束,保留原上下文与 Trace 文件,等待下次触发。传输层 `timeout`/`malformed` 仍走上文「自动重连」一节所述的压缩专用重连上限与退避阶梯。
|
压缩请求**保持会话工具集不变**——请求前缀(含工具列表)与普通轮次逐字节一致,确保上下文最大的时刻提供商的提示词缓存依然有效。只有得到有效摘要,压缩才算成功:若响应中出现工具调用、或提取出的摘要为空,则判为无效并重试——工具调用会先以合成的失败输出逐一应答(保持 `tool_use`/`tool_result` 配对完整),修复后的请求**立即重发**(无效摘要是模型行为而非传输故障,不做退避),最多允许 5 次无效尝试,之后压缩以 `failed` 结束,保留原上下文与 Trace 文件,等待下次触发。传输层 `timeout`/`malformed` 仍走上文「自动重连」一节所述的压缩专用重连上限与退避阶梯。首个**已提交**的尝试(无论被采纳还是被判无效)同时会把折叠进压缩请求的本轮输入(任务中途的工具结果、手动 `/compact` 折叠的补发内容)吸收进旧上下文:重试只重发修复输出与压缩 Prompt,压缩放弃后的续跑也不再重发已吸收的输入。
|
||||||
|
|
||||||
## 并发模型
|
## 并发模型
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user