From e19969fd32d1d0e3fb466cc15b68be9f36764945 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Sun, 9 Aug 2026 01:54:30 +0800 Subject: [PATCH 01/12] fix(session): separate input and output token budgets --- packages/core/src/session/compaction.ts | 13 ++-- packages/core/src/session/runner/model.ts | 2 +- packages/core/test/session-compaction.test.ts | 5 ++ .../core/test/session-runner-model.test.ts | 4 +- .../src/provider/catalog-spec.ts | 33 ++++----- .../deepagent-code/src/session/overflow.ts | 47 +++++++------ .../test/provider/catalog-spec.test.ts | 16 +++++ .../test/session/compaction.test.ts | 51 ++------------ .../test/session/overflow.test.ts | 68 +++++++++++++------ packages/llm/src/schema/options.ts | 1 + 10 files changed, 125 insertions(+), 115 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 0968d1003..ffc869739 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -111,6 +111,10 @@ type Input = { const estimate = (value: unknown) => Token.estimate(JSON.stringify(value)) +export const inputBudget = (context: number, buffer: number) => Math.max(0, context - buffer) + +const modelInputLimit = (model: Model) => model.route.defaults.limits?.input ?? model.route.defaults.limits?.context + const truncate = (value: string) => value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` @@ -214,7 +218,7 @@ export const buildPrompt = (input: { export const make = (dependencies: Dependencies) => { const config = settings(dependencies.config) const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) { - const context = input.model.route.defaults.limits?.context + const context = modelInputLimit(input.model) if (context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 const selected = select(input.entries, config.tokens) @@ -225,7 +229,7 @@ export const make = (dependencies: Dependencies) => { context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean), }) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) - if (Token.estimate(summaryPrompt) > context - summaryOutput) return false + if (Token.estimate(summaryPrompt) > inputBudget(context, config.buffer)) return false const messageID = SessionMessage.ID.create() yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, @@ -268,12 +272,11 @@ export const make = (dependencies: Dependencies) => { }) const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) { if (!config.auto) return false - const context = input.model.route.defaults.limits?.context + const context = modelInputLimit(input.model) if (context === undefined || context <= 0) return false - const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 if ( estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= - context - Math.max(output, config.buffer) + inputBudget(context, config.buffer) ) return false return yield* compactAfterOverflow(input) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index f3cbbcc16..7abe062dc 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -65,7 +65,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { generation: model.request.generation, providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined, http: { body: httpBody }, - limits: { context: model.limit.context, output: model.limit.output }, + limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output }, }) } diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index e0370d9a8..298002cf2 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -38,3 +38,8 @@ test("buildPrompt narrow omitted ⇒ legacy template (unchanged)", () => { expect(legacy).toContain("## Critical Context") expect(legacy).not.toContain("## Data References") }) + +test("inputBudget subtracts only the input-side compaction buffer", () => { + expect(SessionCompaction.inputBudget(1_048_576, 20_000)).toBe(1_028_576) + expect(SessionCompaction.inputBudget(200_000, 20_000)).toBe(180_000) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 179515859..60f6cce25 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -38,7 +38,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => cost: [], status: "active", enabled: true, - limit: { context: 100, output: 20 }, + limit: { context: 100, input: 80, output: 20 }, }) const provider = (api: ProviderV2.Info["api"]) => @@ -64,7 +64,7 @@ describe("SessionRunnerModel", () => { endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, - limits: { context: 100, output: 20 }, + limits: { context: 100, input: 80, output: 20 }, generation: { temperature: 0.7 }, providerOptions: { openai: { store: false, serviceTier: "priority" } }, http: { body: { custom_extension: { enabled: true } } }, diff --git a/packages/deepagent-code/src/provider/catalog-spec.ts b/packages/deepagent-code/src/provider/catalog-spec.ts index 445c18c61..29a0cbc17 100644 --- a/packages/deepagent-code/src/provider/catalog-spec.ts +++ b/packages/deepagent-code/src/provider/catalog-spec.ts @@ -31,7 +31,7 @@ export function normalizeModelID(id: string): string { // Strip a trailing date/version stamp so "claude-3-5-sonnet-20241022" can fall back to // "claude-3-5-sonnet". Only used as a secondary (loose) match after exact-normalized misses. export function stripDateSuffix(normalized: string): string { - return normalized.replace(/-(?:\d{6,8}|v\d+(?:-\d+)*|latest|preview)$/g, "") + return normalized.replace(/-(?:\d{4,8}|v\d+(?:-\d+)*|latest|preview)$/g, "") } export interface CatalogMatch { @@ -82,19 +82,21 @@ export function buildCatalogIndex(catalog: Record): return { exact, loose } } -// Look up catalog specs for a discovered/custom model. Tries the api id then the config id against the -// exact map, then the date-stripped loose map. Returns the matched catalog model or undefined. -export function catalogSpecFor(apiID: string, modelID: string, index: CatalogIndex): ModelsDev.Model | undefined { - const apiKey = normalizeModelID(apiID) - const idKey = normalizeModelID(modelID) - return ( - index.exact.get(apiKey)?.model ?? - index.exact.get(idKey)?.model ?? - index.loose.get(stripDateSuffix(apiKey))?.model ?? - index.loose.get(stripDateSuffix(idKey))?.model +function matchFor(apiID: string, modelID: string, index: CatalogIndex): CatalogMatch | undefined { + const keys = [normalizeModelID(apiID), normalizeModelID(modelID)] + const candidates = keys.flatMap((key) => [index.exact.get(key), index.loose.get(stripDateSuffix(key))]) + return candidates.filter((match): match is CatalogMatch => match !== undefined).reduce( + (best, candidate) => (best ? preferMatch(best, candidate) : candidate), + undefined, ) } +// Look up catalog specs for a discovered/custom model. Compare exact and date-stripped candidates +// together so an exact third-party dated alias cannot outrank the canonical official base model. +export function catalogSpecFor(apiID: string, modelID: string, index: CatalogIndex): ModelsDev.Model | undefined { + return matchFor(apiID, modelID, index)?.model +} + // Small projection of the fields the discover dialog surfaces so the user can preview (and then // override) the auto-filled specs. Undefined when there's no catalog match. export interface ProjectedSpec { @@ -118,12 +120,5 @@ export function projectSpec(match: CatalogMatch): ProjectedSpec { } export function specMatchFor(apiID: string, modelID: string, index: CatalogIndex): CatalogMatch | undefined { - const apiKey = normalizeModelID(apiID) - const idKey = normalizeModelID(modelID) - return ( - index.exact.get(apiKey) ?? - index.exact.get(idKey) ?? - index.loose.get(stripDateSuffix(apiKey)) ?? - index.loose.get(stripDateSuffix(idKey)) - ) + return matchFor(apiID, modelID, index) } diff --git a/packages/deepagent-code/src/session/overflow.ts b/packages/deepagent-code/src/session/overflow.ts index d7a5a8539..f8f7755d0 100644 --- a/packages/deepagent-code/src/session/overflow.ts +++ b/packages/deepagent-code/src/session/overflow.ts @@ -46,6 +46,7 @@ export interface RequestBudgetStatus { readonly reason?: "context_limit_unknown" | "context_limit_invalid" | "physical_budget_exceeded" readonly estimatedFullRequestTokens: number readonly physicalInputBudget: number + // Durable receipt compatibility: this is the independent generation ceiling, not an input deduction. readonly reservedOutputTokens: number readonly safetyMargin: number readonly provenance: "model_limit" | "host_guard" @@ -104,47 +105,52 @@ function positiveEnv(name: string, fallback: number) { return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback } +function physicalInputLimit(model: Provider.Model) { + return model.limit.input ?? model.limit.context +} + export function requestBudget(input: { model: Provider.Model estimatedFullRequestTokens: number outputTokenMax?: number }): RequestBudgetStatus { - const reservedOutputTokens = ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax) + // Output capacity is an independent generation limit. Keep it in the receipt, but never + // subtract it from the provider's input window. + const maxOutputTokens = ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax) const safetyMargin = positiveEnv("DEEPAGENT_CODE_CONTEXT_SAFETY_MARGIN", 1_024) - const context = input.model.limit.context - if (!Number.isFinite(context) || context < 0) { + const inputLimit = physicalInputLimit(input.model) + if (!Number.isFinite(inputLimit) || inputLimit < 0) { return { decision: "unavailable", reason: "context_limit_invalid", estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget: 0, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } } - if (context === 0) { + if (inputLimit === 0) { const hostGuard = positiveEnv("DEEPAGENT_CODE_UNKNOWN_CONTEXT_GUARD", 32_768) return { decision: input.estimatedFullRequestTokens < hostGuard ? "ok" : "unavailable", ...(input.estimatedFullRequestTokens >= hostGuard ? { reason: "context_limit_unknown" as const } : {}), estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget: hostGuard, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "host_guard", } } - const contextBudget = context - reservedOutputTokens - safetyMargin - const physicalInputBudget = input.model.limit.input ? Math.min(input.model.limit.input, contextBudget) : contextBudget + const physicalInputBudget = inputLimit - safetyMargin if (physicalInputBudget <= 0) { return { decision: "unavailable", reason: "context_limit_invalid", estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } @@ -154,25 +160,22 @@ export function requestBudget(input: { ...(input.estimatedFullRequestTokens >= physicalInputBudget ? { reason: "physical_budget_exceeded" as const } : {}), estimatedFullRequestTokens: input.estimatedFullRequestTokens, physicalInputBudget, - reservedOutputTokens, + reservedOutputTokens: maxOutputTokens, safetyMargin, provenance: "model_limit", } } export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) { - const context = input.model.limit.context - // BUG-007: context=0 means "unknown" (resolver fallback). Return 0 so callers that only need the + const inputLimit = physicalInputLimit(input.model) + // BUG-007: a zero input/context fallback means "unknown". Return 0 so callers that only need the // numeric budget get a safe zero, but overflowStatus() uses its own typed path for the "unavailable" // phase rather than treating 0 the same as auto=false. - if (!context) return 0 - - const reserved = - input.cfg.compaction?.reserved ?? - Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) - return input.model.limit.input - ? Math.max(0, input.model.limit.input - reserved) - : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) + if (!inputLimit) return 0 + + // This is input-side room for one more turn and the compaction instruction. Output has its own + // provider limit and does not consume the input window. + return Math.max(0, inputLimit - (input.cfg.compaction?.reserved ?? COMPACTION_BUFFER)) } // Collapse an assistant token record to a single "used" count, matching the historical isOverflow math @@ -208,10 +211,10 @@ export function overflowStatus(input: { const softLine = hardLine * reminderFraction() const fallbackLine = Math.min(hardLine, Math.max(softLine, hardLine - fallbackBuffer())) - // BUG-007: context=0 is the resolver fallback for an *unknown* limit — it must NOT be treated the + // BUG-007: a zero input/context fallback is an *unknown* limit — it must NOT be treated the // same as the user explicitly disabling compaction (auto=false). Return a typed "unavailable" result // so the caller can show a meaningful degraded state / fail-closed guard. - if (!input.model.limit.context) { + if (!physicalInputLimit(input.model)) { return { phase: "unavailable", reason: "context_limit_unknown", diff --git a/packages/deepagent-code/test/provider/catalog-spec.test.ts b/packages/deepagent-code/test/provider/catalog-spec.test.ts index 2501e60d4..c65b7705d 100644 --- a/packages/deepagent-code/test/provider/catalog-spec.test.ts +++ b/packages/deepagent-code/test/provider/catalog-spec.test.ts @@ -46,6 +46,7 @@ describe("normalizeModelID", () => { describe("stripDateSuffix", () => { test("removes trailing date/version stamps", () => { expect(stripDateSuffix("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet") + expect(stripDateSuffix("deepseek-v4-flash-0731")).toBe("deepseek-v4-flash") expect(stripDateSuffix("gpt-4o-latest")).toBe("gpt-4o") expect(stripDateSuffix("model-preview")).toBe("model") expect(stripDateSuffix("gpt-4o")).toBe("gpt-4o") @@ -109,6 +110,21 @@ describe("collision disambiguation", () => { const match = specMatchFor("llama-3", "llama-3", index) expect(match?.providerID).toBe("big") }) + + test("prefers the official base spec over a third-party exact dated collision", () => { + const index = buildCatalogIndex( + catalog({ + // Regression fixture: this bogus third-party limit must never override the official model. + ambient: [model("deepseek/deepseek-v4-flash-0731", { limit: { context: 1_048_576, output: 1_048_576 } })], + deepseek: [ + model("deepseek-v4-flash", { limit: { context: 1_000_000, input: 1_000_000, output: 384_000 } }), + ], + }), + ) + const match = specMatchFor("deepseek-v4-flash-0731", "deepseek-v4-flash-0731", index) + expect(match?.providerID).toBe("deepseek") + expect(match?.model.limit).toEqual({ context: 1_000_000, input: 1_000_000, output: 384_000 }) + }) }) describe("projectSpec", () => { diff --git a/packages/deepagent-code/test/session/compaction.test.ts b/packages/deepagent-code/test/session/compaction.test.ts index e41c7c4c4..ff51144a6 100644 --- a/packages/deepagent-code/test/session/compaction.test.ts +++ b/packages/deepagent-code/test/session/compaction.test.ts @@ -465,82 +465,41 @@ describe("session.compaction.isOverflow", () => { ), ) - // ─── Bug reproduction tests ─────────────────────────────────────────── - // These tests demonstrate that when limit.input is set, isOverflow() - // does not subtract any headroom for the next model response. This means - // compaction only triggers AFTER we've already consumed the full input - // budget, leaving zero room for the next API call's output tokens. - // - // Compare: without limit.input, usable = context - output (reserves space). - // With limit.input, usable = limit.input (reserves nothing). - // - // Related issues: #10634, #8089, #11086, #12621 - // Open PRs: #6875, #12924 - it.live( - "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", + "keeps an input-side compaction buffer when limit.input is set", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K const model = createModel({ context: 200_000, input: 200_000, output: 32_000 }) - - // We've used 198K tokens total. Only 2K under the input limit. - // On the next turn, the full conversation (198K) becomes input, - // plus the model needs room to generate output — this WILL overflow. const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } - // count = 180K + 3K + 15K = 198K - // usable = limit.input = 200K (no output subtracted!) - // 198K > 200K = false → no compaction triggered - - // WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓ - // WITH limit.input: usable = 200K, and 198K > 200K = false ✗ - - // With 198K used and only 2K headroom, the next turn will overflow. - // Compaction MUST trigger here. expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( - "BUG: without limit.input, same token count correctly triggers compaction", + "uses context as the input limit fallback without subtracting output capacity", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - // Same model but without limit.input — uses context - output instead const model = createModel({ context: 200_000, output: 32_000 }) - - // Same token usage as above const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } - // count = 198K - // usable = context - output = 200K - 32K = 168K - // 198K > 168K = true → compaction correctly triggered - - const result = yield* compact.isOverflow({ tokens, model }) - expect(result).toBe(true) // ← Correct: headroom is reserved + expect(yield* compact.isOverflow({ tokens, model })).toBe(true) }), ), ) it.live( - "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", + "uses the same input-side threshold with explicit input and context fallback", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service - // Two models with identical context/output limits, differing only in limit.input const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 }) const withoutInputLimit = createModel({ context: 200_000, output: 32_000 }) - - // 170K total tokens — well above context-output (168K) but below input limit (200K) const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } } - const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit }) const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit }) - - // Both models have identical real capacity — they should agree: - expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output) - expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K) + expect(withLimit).toBe(withoutLimit) }), ), ) diff --git a/packages/deepagent-code/test/session/overflow.test.ts b/packages/deepagent-code/test/session/overflow.test.ts index c715b0f18..7e295982b 100644 --- a/packages/deepagent-code/test/session/overflow.test.ts +++ b/packages/deepagent-code/test/session/overflow.test.ts @@ -18,9 +18,8 @@ import { REMINDER_DEBOUNCE_TURNS, } from "@/session/overflow" -// A model whose `input` limit is the direct usable budget knob. reserved defaults to -// min(COMPACTION_BUFFER=20_000, maxOutputTokens); with output=10_000 → reserved=10_000, so -// usable() = input - 10_000. We choose input=110_000 → usable=100_000 for round numbers. +// A model whose `input` limit is the direct usable budget knob. Compaction keeps an independent +// 20_000-token input buffer, so usable() = 110_000 - 20_000 = 90_000. function model(opts?: { context?: number; input?: number; output?: number }): Provider.Model { return { id: "test-model", @@ -28,7 +27,7 @@ function model(opts?: { context?: number; input?: number; output?: number }): Pr name: "Test", limit: { context: opts?.context ?? 200_000, - input: opts?.input ?? 110_000, + input: opts && "input" in opts ? opts.input : 110_000, output: opts?.output ?? 10_000, }, cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, @@ -59,9 +58,9 @@ const tokensFor = (total: number) => ({ describe("overflowStatus lines", () => { const m = model() const c = cfg() - const hard = usable({ cfg: c, model: m }) // 100_000 - const soft = hard * REMINDER_FRACTION // 80_000 - const fallback = hard - AUTO_COMPACT_FALLBACK_BUFFER // 88_000 + const hard = usable({ cfg: c, model: m }) // 90_000 + const soft = hard * REMINDER_FRACTION // 72_000 + const fallback = hard - AUTO_COMPACT_FALLBACK_BUFFER // 78_000 test("computes the three monotonic lines", () => { const st = overflowStatus({ cfg: c, model: m, tokens: 0 }) @@ -119,7 +118,7 @@ describe("overflowStatus lines", () => { test("context===0 (unknown limit) returns unavailable, not ok", () => { // BUG-007 RC-3: context=0 is the fallback for an *unknown* limit — it must return a typed // "unavailable" phase with reason "context_limit_unknown", NOT the same "ok" as auto=false. - const zero = model({ context: 0 }) + const zero = model({ context: 0, input: undefined }) const st = overflowStatus({ cfg: c, model: zero, tokens: 1_000_000 }) expect(st.phase).toBe("unavailable") expect(st.reason).toBe("context_limit_unknown") @@ -153,19 +152,48 @@ describe("overflowStatus lines", () => { }) describe("requestBudget complete-request preflight (BUG-007)", () => { - test("uses the smaller input/context physical budget and never deducts prefix", () => { + test("uses the explicit input limit and keeps an input-side safety margin", () => { const status = requestBudget({ model: model(), estimatedFullRequestTokens: 110_000 }) - expect(status.physicalInputBudget).toBe(110_000) + expect(status.physicalInputBudget).toBe(108_976) expect(status.decision).toBe("unavailable") expect(status.provenance).toBe("model_limit") }) + test("uses the canonical DeepSeek 1M input / 384K output limits independently", () => { + const status = requestBudget({ + model: model({ context: 1_000_000, input: 1_000_000, output: 384_000 }), + estimatedFullRequestTokens: 297_000, + }) + expect(status.physicalInputBudget).toBe(998_976) + expect(status.reservedOutputTokens).toBe(384_000) + expect(status.decision).toBe("ok") + }) + + test("prefers an explicit input limit when context is unavailable", () => { + const status = requestBudget({ + model: model({ context: 0, input: 200_000, output: 128_000 }), + estimatedFullRequestTokens: 100_000, + }) + expect(status.physicalInputBudget).toBe(198_976) + expect(status.provenance).toBe("model_limit") + expect(status.decision).toBe("ok") + }) + + test("output limit does not change request or compaction input budgets", () => { + const smallOutput = model({ context: 200_000, input: undefined, output: 8_000 }) + const largeOutput = model({ context: 200_000, input: undefined, output: 128_000 }) + expect(requestBudget({ model: smallOutput, estimatedFullRequestTokens: 100_000 }).physicalInputBudget).toBe( + requestBudget({ model: largeOutput, estimatedFullRequestTokens: 100_000 }).physicalInputBudget, + ) + expect(usable({ cfg: cfg(), model: smallOutput })).toBe(usable({ cfg: cfg(), model: largeOutput })) + }) + test("allows a short unknown-limit request but fails closed past the host guard", () => { const previous = process.env["DEEPAGENT_CODE_UNKNOWN_CONTEXT_GUARD"] process.env["DEEPAGENT_CODE_UNKNOWN_CONTEXT_GUARD"] = "1000" try { - const short = requestBudget({ model: model({ context: 0 }), estimatedFullRequestTokens: 999 }) - const long = requestBudget({ model: model({ context: 0 }), estimatedFullRequestTokens: 1000 }) + const short = requestBudget({ model: model({ context: 0, input: undefined }), estimatedFullRequestTokens: 999 }) + const long = requestBudget({ model: model({ context: 0, input: undefined }), estimatedFullRequestTokens: 1000 }) expect(short.decision).toBe("ok") expect(short.provenance).toBe("host_guard") expect(long.decision).toBe("unavailable") @@ -177,7 +205,7 @@ describe("requestBudget complete-request preflight (BUG-007)", () => { }) test("fails closed for an invalid negative context limit", () => { - const status = requestBudget({ model: model({ context: -1 }), estimatedFullRequestTokens: 1 }) + const status = requestBudget({ model: model({ context: -1, input: undefined }), estimatedFullRequestTokens: 1 }) expect(status.decision).toBe("unavailable") expect(status.reason).toBe("context_limit_invalid") expect(status.provenance).toBe("model_limit") @@ -200,7 +228,7 @@ describe("softLandingDecision state machine", () => { }) test("unknown limit emits an explicit guard action", () => { - const unknown = overflowStatus({ cfg: c, model: model({ context: 0 }), tokens: 1 }) + const unknown = overflowStatus({ cfg: c, model: model({ context: 0, input: undefined }), tokens: 1 }) const state = initialSoftLandingState const decision = softLandingDecision({ status: unknown, state, step: 3 }) expect(decision.action).toBe("guard") @@ -326,14 +354,14 @@ describe("overflowStatus: raw tokens drive all phase thresholds (BUG-007 RC-5)", test("prefix=0 leaves raw==used, behaviour is unchanged", () => { // With no prefix, raw==used and the phase logic is unchanged. expect(overflowStatus({ cfg: c, model: m, tokens: 70_000, prefixTokens: 0 }).phase).toBe("ok") - expect(overflowStatus({ cfg: c, model: m, tokens: 85_000, prefixTokens: 0 }).phase).toBe("reminder") + expect(overflowStatus({ cfg: c, model: m, tokens: 75_000, prefixTokens: 0 }).phase).toBe("reminder") }) - test("prefix does NOT de-escalate phase — raw 95k >= fallbackLine 88k → fallback", () => { - // BUG-007 fix: old code deducted prefix first → body=85k → 'reminder'. Correct: raw 95k - // already crosses fallbackLine 88k, so the phase must be 'fallback' regardless of prefix. - const st = overflowStatus({ cfg: c, model: m, tokens: 95_000, prefixTokens: 10_000 }) - expect(st.used).toBe(85_000) // `used` kept for diagnostics (cache/cost) + test("prefix does NOT de-escalate phase — raw 85k >= fallbackLine 78k → fallback", () => { + // BUG-007 fix: old code deducted prefix first → body=75k → 'reminder'. Correct: raw 85k + // already crosses fallbackLine 78k, so the phase must be 'fallback' regardless of prefix. + const st = overflowStatus({ cfg: c, model: m, tokens: 85_000, prefixTokens: 10_000 }) + expect(st.used).toBe(75_000) // `used` kept for diagnostics (cache/cost) expect(st.phase).toBe("fallback") // was "reminder" — that was the bug }) }) diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index c02af6d1e..d982595d7 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -123,6 +123,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray("LLM.ModelLimits")({ context: Schema.optional(Schema.Number), + input: Schema.optional(Schema.Number), output: Schema.optional(Schema.Number), }) {} From 31098f36aa451104842faac8667d2000442d46de Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Sun, 9 Aug 2026 10:44:43 +0800 Subject: [PATCH 02/12] fix(deepagent-code): merge model plan advances server-side --- packages/core/src/agent-gateway.ts | 15 +- .../core/src/deepagent/plan-controller.ts | 69 +- packages/core/src/deepagent/prompt-policy.ts | 44 +- .../test/deepagent/plan-controller.test.ts | 80 +- .../core/test/deepagent/prompt-policy.test.ts | 21 + packages/deepagent-code/package.json | 3 +- .../script/live-llm/dispatcher.ts | 4 + .../script/live-llm/finalizer-isolation.ts | 98 +- .../script/live-llm/plan-advance-contract.ts | 226 ++++ .../script/live-llm/plan-advance-oracle.ts | 226 ++++ .../deepagent-code/script/live-llm/routes.ts | 24 + .../deepagent-code/script/live-llm/runtime.ts | 124 +- .../deepagent-code/src/session/compaction.ts | 1101 ++++++++++++---- .../deepagent-code/src/session/llm/request.ts | 39 +- .../deepagent-code/src/session/processor.ts | 175 ++- packages/deepagent-code/src/session/prompt.ts | 1128 ++++++++++++++--- .../deepagent-code/src/session/reminders.ts | 7 +- .../src/session/task-delivery.ts | 1 + .../deepagent-code/src/tool/plan-write.ts | 320 ++++- .../deepagent-code/src/tool/plan-write.txt | 37 +- packages/deepagent-code/src/tool/task.ts | 65 +- packages/deepagent-code/src/tool/task_read.ts | 55 +- .../test/control-plane/delivery.test.ts | 9 + .../test/deepagent/plan-status-cache.test.ts | 99 +- .../live-llm-plan-advance-oracle.test.ts | 166 +++ .../test/script/live-llm-routes.test.ts | 22 + .../test/script/run-live-llm-all.test.ts | 40 +- .../test/session/compaction.test.ts | 5 + .../test/session/processor-effect.test.ts | 20 +- .../test/session/prompt.test.ts | 726 ++++++++++- .../test/session/structured-output.test.ts | 9 + .../session/tool-sequence-tracker.test.ts | 159 ++- .../test/tool/parameters.test.ts | 33 + .../test/tool/plan-write.test.ts | 423 +++++++ .../test/tool/task-finalizer.test.ts | 82 +- .../test/tool/task-read.test.ts | 84 +- packages/llm/script/live-llm/config.ts | 75 +- script/run-live-llm-all.ts | 6 + 38 files changed, 5145 insertions(+), 675 deletions(-) create mode 100644 packages/deepagent-code/script/live-llm/plan-advance-contract.ts create mode 100644 packages/deepagent-code/script/live-llm/plan-advance-oracle.ts create mode 100644 packages/deepagent-code/test/script/live-llm-plan-advance-oracle.test.ts create mode 100644 packages/deepagent-code/test/tool/plan-write.test.ts diff --git a/packages/core/src/agent-gateway.ts b/packages/core/src/agent-gateway.ts index f7266ec5a..e12313894 100644 --- a/packages/core/src/agent-gateway.ts +++ b/packages/core/src/agent-gateway.ts @@ -400,7 +400,9 @@ export const isDeepAgentProvider = (providerID: string) => providerID === "deepa // V3.1 global runtime: activation is strength-driven and provider-agnostic. The runtime is // active for high/max on every provider; general (and a disabled/killed runtime) is passthrough. -export const isActiveDeepAgentRuntime = () => current.enabled && !current.killSwitch && current.agentMode !== "general" +export const isDeepAgentRuntimeEnabled = () => current.enabled && !current.killSwitch + +export const isActiveDeepAgentRuntime = () => isDeepAgentRuntimeEnabled() && current.agentMode !== "general" const isManagedDeepAgentRuntimeWith = (config: CurrentConfig) => config.enabled && !config.killSwitch && config.agentMode !== "general" @@ -408,6 +410,7 @@ const isManagedDeepAgentRuntimeWith = (config: CurrentConfig) => import { buildSystemPrompt, buildVolatileContinuationContext, + buildVolatilePlanContext, buildVolatileRoundContext, type KnowledgeRefProjection, type PromptContext, @@ -486,11 +489,13 @@ export const systemPrompt = (_providerID: string, context?: PromptContext) => // the cache breakpoint) so the model still sees round/stage/previous-results/budget without churning // the prefix. Returns "" when there is nothing round-specific (⇒ caller skips injection). Only emitted // when the DeepAgent runtime is active, matching systemPrompt(). -export const volatileRoundContext = (context: PromptContext): string => - isActiveDeepAgentRuntime() ? buildVolatileRoundContext(context) : "" +export const volatileRoundContext = (context: PromptContext, runtimeControl?: string): string => + isActiveDeepAgentRuntime() ? buildVolatileRoundContext(context, runtimeControl) : "" + +export const volatileContinuationContext = (runtimeControl?: string): string => + isActiveDeepAgentRuntime() ? buildVolatileContinuationContext(runtimeControl) : "" -export const volatileContinuationContext = (): string => - isActiveDeepAgentRuntime() ? buildVolatileContinuationContext() : "" +export const volatilePlanContext = (runtimeControl: string): string => buildVolatilePlanContext(runtimeControl) export const preflight = (input: RunInput): Effect.Effect => preflightWith(input, current) diff --git a/packages/core/src/deepagent/plan-controller.ts b/packages/core/src/deepagent/plan-controller.ts index c6d7edf1d..e7f8cba8d 100644 --- a/packages/core/src/deepagent/plan-controller.ts +++ b/packages/core/src/deepagent/plan-controller.ts @@ -222,7 +222,8 @@ export type PlanWriteInput = { readonly assigned_agent?: string | null readonly note?: string | null }[] - readonly active_step_id: string | null + /** Omit to derive from the single active step after missing step IDs are allocated. */ + readonly active_step_id?: string | null } const isRecord = (value: unknown): value is Record => @@ -243,7 +244,10 @@ export const decodePlanWriteInput = (value: unknown): PlanWriteInput | null => { ) return null if (typeof value.goal !== "string" || !Array.isArray(value.steps)) return null - if (!(value.active_step_id === null || typeof value.active_step_id === "string")) return null + if ( + !(value.active_step_id === undefined || value.active_step_id === null || typeof value.active_step_id === "string") + ) + return null if (value.replan_reason !== undefined && typeof value.replan_reason !== "string") return null if ( value.assumptions !== undefined && @@ -276,7 +280,7 @@ export const decodePlanWriteInput = (value: unknown): PlanWriteInput | null => { goal: value.goal, ...(Array.isArray(value.assumptions) ? { assumptions: value.assumptions as string[] } : {}), steps: steps.filter((step): step is NonNullable => step != null), - active_step_id: value.active_step_id, + ...(value.active_step_id !== undefined ? { active_step_id: value.active_step_id } : {}), } } @@ -330,7 +334,7 @@ export class PlanConflictError extends Error { } } -const normalizeStatus = (status: string): PlanStepStatus | undefined => { +export const normalizePlanStepStatus = (status: string): PlanStepStatus | undefined => { const normalized = status.trim().toLowerCase() if (STEP_STATUSES.has(normalized as PlanStepStatus)) return normalized as PlanStepStatus return STATUS_ALIASES[normalized] @@ -419,7 +423,11 @@ export const planProgressFingerprint = (plan: PlanDoc): string => })), }) -const requireExpected = (input: PlanWriteInput, previous: PlanDoc | null, ref: PlanExpected | null): void => { +export const requirePlanWriteExpected = ( + input: Pick, + previous: PlanDoc | null, + ref: PlanExpected | null, +): void => { if (input.operation === "create") { if (input.expected_plan_id !== null || input.expected_version !== null) { throw new PlanValidationError("invalid_precondition", [], previous?.plan_id ?? null, ref?.version ?? null) @@ -456,7 +464,7 @@ export const buildPlanFromWriteInput = ( if (!("create" === input.operation || "advance" === input.operation || "replan" === input.operation)) { throw new PlanValidationError("invalid_operation") } - requireExpected(input, previous, ref) + requirePlanWriteExpected(input, previous, ref) if (normalizedText(input.goal) === "") throw new PlanValidationError("empty_goal", [], previous?.plan_id ?? null, ref?.version ?? null) if (input.steps.length === 0) @@ -493,7 +501,7 @@ export const buildPlanFromWriteInput = ( if (normalizedText(step.title) === "") { throw new PlanValidationError("empty_title", [], previous?.plan_id ?? null, ref?.version ?? null) } - const status = normalizeStatus(step.status) + const status = normalizePlanStepStatus(step.status) if (!status) throw new PlanValidationError("invalid_status", [], previous?.plan_id ?? null, ref?.version ?? null) const suppliedID = normalizedText(step.step_id) if (input.operation === "advance" && suppliedID === "") { @@ -543,16 +551,14 @@ export const buildPlanFromWriteInput = ( "multiple_active_steps", active.map((step) => step.step_id), ) - if (input.active_step_id !== null && !used.has(input.active_step_id)) { - throw new PlanValidationError("invalid_active_step", [input.active_step_id]) + const activeStepID = input.active_step_id === undefined ? (active[0]?.step_id ?? null) : input.active_step_id + if (activeStepID !== null && !used.has(activeStepID)) { + throw new PlanValidationError("invalid_active_step", [activeStepID]) } - if ( - (input.active_step_id === null && active.length > 0) || - (input.active_step_id !== null && active[0]?.step_id !== input.active_step_id) - ) { + if ((activeStepID === null && active.length > 0) || (activeStepID !== null && active[0]?.step_id !== activeStepID)) { throw new PlanValidationError( "invalid_active_step", - input.active_step_id ? [input.active_step_id] : active.map((step) => step.step_id), + activeStepID ? [activeStepID] : active.map((step) => step.step_id), ) } const blocked = steps.filter((step) => step.status === "blocked" && normalizedText(step.note) === "") @@ -570,7 +576,7 @@ export const buildPlanFromWriteInput = ( (value) => value.trim(), ), steps, - active_step_id: input.active_step_id, + active_step_id: activeStepID, replan_reason: input.operation === "replan" ? normalizedText(input.replan_reason) : (previous?.replan_reason ?? null), last_write_activity_id: null, @@ -761,22 +767,41 @@ export const planStatusesChanged = (previous: PlanDoc | null | undefined, next: export const formatStepChange = (c: StepStatusChange): string => c.from === null ? `${c.title}: →${c.to}` : `${c.title}: ${c.from}→${c.to}` -// Compact, constant-size plan snapshot re-injected into context each turn (high+ only) so the model -// can SEE its own checklist and report against it. One line per step; the full form includes goal + -// progress, while tool continuations omit the already-adjacent goal. We deliberately omit -// acceptance/assumptions/evidence so it cannot grow with history. +// Compact, constant-size plan snapshot re-injected into context so the model can SEE its checklist +// and copy every model-owned identity parameter without consulting history. One line per step; the +// full form includes goal + progress, while tool continuations omit the already-adjacent goal. We +// deliberately omit acceptance/assumptions/evidence so it cannot grow with history. The model-facing +// plan tool treats advance as a server-merged status patch, so those omitted server-owned fields are +// recovered from the authoritative document rather than reconstructed from this compact snapshot. +const renderPlanContextValue = (value: string): string => + JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026") + export const renderPlanSnapshot = (plan: PlanDoc, detail: "full" | "continuation" = "full"): string => { const { done, total } = planProgress(plan) const active = plan.steps.find((s) => s.step_id === plan.active_step_id) ?? null - const lines = plan.steps.map((s) => `[${STATUS_MARK[s.status]}] ${s.title}`) + const lines = plan.steps.map( + (s) => + `[${STATUS_MARK[s.status]}] step_id=${renderPlanContextValue(s.step_id)} status=${renderPlanContextValue(s.status)} title=${renderPlanContextValue(s.title)}${s.status === "blocked" && s.note != null ? ` note=${renderPlanContextValue(s.note)}` : ""}`, + ) const header = detail === "continuation" ? `Current plan (${done}/${total} done)` - : `Current plan (${done}/${total} done) — goal: ${plan.goal}` - const activeLine = active ? `Active step: ${active.title}` : "No step is marked active." + : `Current plan (${done}/${total} done) — goal: ${renderPlanContextValue(plan.goal)}` + const activeLine = active + ? `Active step: active_step_id=${renderPlanContextValue(active.step_id)} title=${renderPlanContextValue(active.title)}` + : "Active step: active_step_id=null" return `${header}\n${lines.join("\n")}\n${activeLine}` } +export const renderPlanWritePrecondition = (planID: string, version: number): string => + `Plan write precondition: expected_plan_id=${renderPlanContextValue(planID)} expected_version=${version}` + +export const renderPlanWriteContext = ( + plan: PlanDoc, + version: number, + detail: "full" | "continuation" = "full", +): string => `${renderPlanSnapshot(plan, detail)}\n${renderPlanWritePrecondition(plan.plan_id, version)}` + // Progress-nudge budget (the COUNT BACKSTOP of the hybrid trigger). This is deliberately NOT the // primary signal: raw edit count conflates "the step is genuinely large" with "the model forgot to // report". The count only guarantees the model is reminded eventually when no semantic boundary diff --git a/packages/core/src/deepagent/prompt-policy.ts b/packages/core/src/deepagent/prompt-policy.ts index c4c19a41f..c0300fabd 100644 --- a/packages/core/src/deepagent/prompt-policy.ts +++ b/packages/core/src/deepagent/prompt-policy.ts @@ -159,7 +159,7 @@ export const buildSystemPrompt = (ctx: PromptContext): string => { // Volatile per-turn state that must NOT enter the cached base system prompt. Rendered into a single // `` block that the caller appends after durable history. The stable system // prompt establishes this tag as trusted runtime control. Only buildSystemPrompt must stay stable. -export const buildVolatileRoundContext = (ctx: PromptContext): string => { +export const buildVolatileRoundContext = (ctx: PromptContext, runtimeControl?: string): string => { const sections: string[] = [] // Round + activation stage: the model's sense of "where am I in the loop". Was previously baked @@ -210,25 +210,41 @@ export const buildVolatileRoundContext = (ctx: PromptContext): string => { ) } - const body = sections.filter(Boolean).join("\n\n") - if (!body) return "" - return ["", body, ""].join("\n") + if (runtimeControl) sections.push(runtimeControl) + return wrapVolatileRoundContext(sections) } // Tool continuations already have the current user request, assistant decision, tool call, and tool // result in adjacent durable history. Repeating the full activation/task/previous-results block after // every tool result makes that control block look like a fresh user request and can induce semantic // restatement loops. Keep only an explicit, constant-size continuation directive in the volatile tail; -// live plan state is appended separately by the request layer. -export const buildVolatileContinuationContext = (): string => - [ - "", - "# Tool continuation", - "", - "Continue directly from the immediately preceding tool result.", - "Apply runtime and plan control state silently. Do not restate or re-summarize the user request, the current phase, or conclusions already established unless the tool result materially changes them.", - "", - ].join("\n") +// live plan state is included in the same trusted control block by the request layer. +export const buildVolatileContinuationContext = (runtimeControl?: string): string => + wrapVolatileRoundContext([ + [ + "# Tool continuation", + "", + "Continue directly from the immediately preceding tool result.", + "Apply runtime and plan control state silently. Do not restate or re-summarize the user request, the current phase, or conclusions already established unless the tool result materially changes them.", + ].join("\n"), + ...(runtimeControl ? [runtimeControl] : []), + ]) + +export const buildVolatilePlanContext = (runtimeControl: string): string => + wrapVolatileRoundContext([ + [ + "# Plan control", + "", + "Apply this runtime plan state silently. Copy required plan tool parameters exactly as shown; never infer identities or versions from conversation history, titles, or positions.", + ].join("\n"), + runtimeControl, + ]) + +const wrapVolatileRoundContext = (sections: string[]): string => { + const body = sections.filter(Boolean).join("\n\n") + if (!body) return "" + return ["", body, ""].join("\n") +} const identitySection = (mode: AgentMode): string => { // P2-1: ultra must not fall through to the High label. Each strength has its own label. diff --git a/packages/core/test/deepagent/plan-controller.test.ts b/packages/core/test/deepagent/plan-controller.test.ts index db00d2480..fd9931813 100644 --- a/packages/core/test/deepagent/plan-controller.test.ts +++ b/packages/core/test/deepagent/plan-controller.test.ts @@ -22,6 +22,8 @@ import { planStatusesChanged, formatStepChange, renderPlanSnapshot, + renderPlanWriteContext, + renderPlanWritePrecondition, shouldNudgeReport, nudgeTrigger, nudgeMutationThreshold, @@ -277,6 +279,39 @@ describe("strict plan write admission", () => { expect(plan.active_step_id).toBe("s1") }) + test("derives active_step_id after allocating missing create step IDs", () => { + const write = input({ + steps: [ + { title: "implement", status: "active", acceptance: "tests pass" }, + { title: "verify", status: "pending", acceptance: "review complete" }, + ], + active_step_id: undefined, + }) + const decoded = decodePlanWriteInput({ + ...write, + active_step_id: undefined, + }) + const plan = buildPlanFromWriteInput("s1", decoded!, null, null) + + expect(decoded).not.toBeNull() + expect(plan.active_step_id).toBe(plan.steps[0]!.step_id) + expect(plan.steps[0]!.step_id).toStartWith("step_") + }) + + test("keeps explicit null distinct from omitted active-step derivation", () => { + expect(() => + buildPlanFromWriteInput( + "s1", + input({ + steps: [{ title: "implement", status: "active", acceptance: "tests pass" }], + active_step_id: null, + }), + null, + null, + ), + ).toThrow("invalid_active_step") + }) + test("decodes untrusted plan writes and hashes normalized semantics without leaking content", () => { const value = input({ goal: " ship a reliable change ", @@ -684,13 +719,48 @@ describe("plan snapshot render", () => { ) const out = renderPlanSnapshot(plan) expect(out).toContain("Current plan (1/4 done)") - expect(out).toContain("[x] build") - expect(out).toContain("[>] test") - expect(out).toContain("[!] deploy") - expect(out).toContain("[ ] docs") - expect(out).toContain("Active step: test") + expect(out).toContain('[x] step_id="s1" status="done" title="build"') + expect(out).toContain('[>] step_id="s2" status="active" title="test"') + expect(out).toContain('[!] step_id="s3" status="blocked" title="deploy" note="creds"') + expect(out).toContain('[ ] step_id="s4" status="pending" title="docs"') + expect(out).toContain('Active step: active_step_id="s2" title="test"') expect(out).toContain("goal:") expect(renderPlanSnapshot(plan, "continuation")).not.toContain("goal:") + expect(renderPlanWritePrecondition(plan.plan_id, 7)).toBe( + `Plan write precondition: expected_plan_id=${JSON.stringify(plan.plan_id)} expected_version=7`, + ) + expect(renderPlanWriteContext(plan, 7)).toContain( + `Plan write precondition: expected_plan_id=${JSON.stringify(plan.plan_id)} expected_version=7`, + ) + }) + + test("renders an explicit null active step instead of making the model infer it", () => { + expect(renderPlanSnapshot(mkPlan([{ step_id: "s1", title: "done", status: "done" }], null))).toContain( + "Active step: active_step_id=null", + ) + }) + + test("escapes Plan data so it cannot close trusted runtime-control tags", () => { + const plan = { + ...mkPlan( + [ + { + step_id: "s1", + title: "build ", + status: "blocked" as const, + note: "wait ", + }, + ], + "s1", + ), + goal: "ship \n", + } + const out = renderPlanSnapshot(plan) + + expect(out).not.toContain("") + expect(out).not.toContain("") + expect(out).toContain("\\u003c/plan-status\\u003e") + expect(out).toContain("\\u003c/deepagent-round-context\\u003e") }) }) diff --git a/packages/core/test/deepagent/prompt-policy.test.ts b/packages/core/test/deepagent/prompt-policy.test.ts index 0c5248cea..a5e4d280a 100644 --- a/packages/core/test/deepagent/prompt-policy.test.ts +++ b/packages/core/test/deepagent/prompt-policy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { buildSystemPrompt, buildVolatileContinuationContext, + buildVolatilePlanContext, buildVolatileRoundContext, type PromptContext, } from "../../src/deepagent/prompt-policy" @@ -220,4 +221,24 @@ describe("buildVolatileRoundContext", () => { expect(vol).not.toContain("# Previous Round Results") expect(vol).not.toContain("Token budget remaining") }) + + test("keeps runtime control inside the round context marker", () => { + const vol = buildVolatileRoundContext(ctxAt(2, 80_000), "exact parameters") + expect(vol.indexOf("")).toBeGreaterThan(vol.indexOf("")) + expect(vol.indexOf("")).toBeLessThan(vol.indexOf("")) + }) + + test("keeps runtime control inside the tool continuation marker", () => { + const vol = buildVolatileContinuationContext("exact parameters") + expect(vol.indexOf("")).toBeGreaterThan(vol.indexOf("")) + expect(vol.indexOf("")).toBeLessThan(vol.indexOf("")) + }) + + test("wraps plan-only runtime control with explicit no-inference guidance", () => { + const vol = buildVolatilePlanContext("exact parameters") + expect(vol).toStartWith("") + expect(vol).toContain("Copy required plan tool parameters exactly as shown") + expect(vol).toContain("never infer identities or versions") + expect(vol).toEndWith("") + }) }) diff --git a/packages/deepagent-code/package.json b/packages/deepagent-code/package.json index a7aaa8241..332e23bb3 100644 --- a/packages/deepagent-code/package.json +++ b/packages/deepagent-code/package.json @@ -10,7 +10,7 @@ "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:llm-routes": "bun test --timeout 30000 test/script/live-llm-routes.test.ts test/script/run-live-llm-all.test.ts", - "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", + "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts test/script/live-llm-plan-advance-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", "test:llm-live:cli-headless": "bun run script/live-llm/cli-headless.ts", "test:llm-ext:goal-cli": "bun run script/live-llm/cli-goal-loop.ts", "test:llm-live:structured-legacy": "bun run script/live-llm/structured-output-legacy.ts", @@ -27,6 +27,7 @@ "test:llm-live:stale-validation": "bun run script/live-llm/stale-validation.ts", "test:llm-live:continuation-repetition": "bun run script/live-llm/continuation-repetition.ts", "test:llm-live:degeneration": "bun run script/live-llm/degeneration.ts", + "test:llm-live:plan-advance": "bun run script/live-llm/plan-advance-contract.ts", "test:llm-ext:finalizer-isolation": "bun run script/live-llm/finalizer-isolation.ts", "test:llm-live:steer-boundary": "bun run script/live-llm/steer-boundary.ts", "test:llm-ext:subagent-worktree": "bun run script/live-llm/subagent-worktree.ts", diff --git a/packages/deepagent-code/script/live-llm/dispatcher.ts b/packages/deepagent-code/script/live-llm/dispatcher.ts index 62e7e974d..217ad8a0b 100644 --- a/packages/deepagent-code/script/live-llm/dispatcher.ts +++ b/packages/deepagent-code/script/live-llm/dispatcher.ts @@ -195,6 +195,10 @@ const modelCommands = new Map([ command("packages/deepagent-code", "bun", "run", "test:llm-live:continuation-repetition"), ], ["live:legacy-session:degeneration", command("packages/deepagent-code", "bun", "run", "test:llm-live:degeneration")], + [ + "live:legacy-session:plan-advance-contract", + command("packages/deepagent-code", "bun", "run", "test:llm-live:plan-advance"), + ], [ "ext:legacy-session:subagent-finalizer-isolation", command("packages/deepagent-code", "bun", "run", "test:llm-ext:finalizer-isolation"), diff --git a/packages/deepagent-code/script/live-llm/finalizer-isolation.ts b/packages/deepagent-code/script/live-llm/finalizer-isolation.ts index 3f3935995..cc001afdf 100644 --- a/packages/deepagent-code/script/live-llm/finalizer-isolation.ts +++ b/packages/deepagent-code/script/live-llm/finalizer-isolation.ts @@ -5,12 +5,12 @@ import { runLegacyLiveCases } from "./runtime" // Suite D1 (design/real-llm-testing.md) — subagent finalizer isolation. The researcher child runs two // phases in ONE child Session: a research turn with the normal read-only registry, then a bounded -// finalizer turn whose registry is emptied down to `StructuredOutput` alone (prompt.ts: `tools = -// finalizerMode ? {} : SessionTools.resolve(...)`). The regression this guards: research-phase tools +// finalizer turn whose registry is emptied down to `StructuredOutput` alone in strict mode (prompt.ts: +// `tools = finalizerMode ? {} : SessionTools.resolve(...)`); the text fallback has no tools. The regression this guards: research-phase tools // leaking into the finalizer turn, which produced empty/invalid structured results or let the model -// keep researching instead of finalizing. The finalizer turn is identified durably — it is the only -// child assistant carrying a non-null `structured` (the research prompt has no `format`, so -// `structured` stays undefined there). +// keep researching instead of finalizing. A compliant provider returns one StructuredOutput call; +// a format-weaker provider may use the bounded second text-only finalizer, whose JSON is still +// validated locally by the task controller. const markers = { module: `module-${crypto.randomUUID()}`, mechanism: `mechanism-${crypto.randomUUID()}`, @@ -81,19 +81,30 @@ if ( throw new Error("Child research phase did not read the fixture through a completed read tool") } const structuredCalls = childTools.filter((tool) => tool.name === "StructuredOutput" && tool.status === "completed") -if (structuredCalls.length !== 1) { +if (structuredCalls.length > 1) { throw new Error( - `Expected exactly one completed child StructuredOutput call, received ${structuredCalls.length}: ` + + `Expected at most one completed child StructuredOutput call, received ${structuredCalls.length}: ` + childTools.map((tool) => `${tool.name}:${tool.status}`).join(", "), ) } -const finalizers = child.assistants.filter((assistant) => assistant.structured !== undefined) -if (finalizers.length !== 1) { - throw new Error(`Expected exactly one child finalizer turn, received ${finalizers.length}`) +const strictFinalizer = child.assistants.find((assistant) => assistant.structured !== undefined) +const textFallbackUsers = child.users.filter( + (user) => nestedRecordOptional(user.metadata, ["deepagent", "structured_finalizer"])?.allow_text === true, +) +const textFinalizers = child.assistants.filter((assistant) => extractJson(assistant.text) !== undefined) +const finalizer = strictFinalizer ?? textFinalizers.at(-1) +if ( + !finalizer || + (strictFinalizer && structuredCalls.length !== 1) || + (!strictFinalizer && textFallbackUsers.length === 0) +) { + throw new Error( + `Expected one structured or validated text finalizer turn, received strict=${strictFinalizer ? 1 : 0}, ` + + `text=${textFinalizers.length}, text_fallback_users=${textFallbackUsers.length}`, + ) } // The finalizer's OWN tools array is the isolation oracle: a leaked research tool would land as a // completed part on this same assistant message, not on an earlier research turn. -const finalizer = finalizers[0] const foreignFinalizerTools = finalizer.tools.filter( (tool) => tool.status === "completed" && tool.name !== "StructuredOutput", ) @@ -102,14 +113,17 @@ if (foreignFinalizerTools.length > 0) { `Finalizer turn executed research-phase tools: ${foreignFinalizerTools.map((tool) => tool.name).join(", ")}`, ) } -if (!finalizer.tools.some((tool) => tool.name === "StructuredOutput" && tool.status === "completed")) { +if ( + strictFinalizer && + !finalizer.tools.some((tool) => tool.name === "StructuredOutput" && tool.status === "completed") +) { throw new Error("Finalizer turn carries a structured result without its own completed StructuredOutput call") } const subagent = nestedRecord(child.metadata, ["deepagent", "subagent"]) if (subagent.state !== "completed" || subagent.finished !== true || subagent.reason !== "structured_output_valid") { throw new Error(`Child durable metadata is not a valid completed structured result: ${JSON.stringify(subagent)}`) } -const result = record(finalizer.structured, "ResearchResult") +const result = record(finalizer.structured ?? extractJson(finalizer.text), "ResearchResult") if (result.module !== markers.module || result.mechanism !== markers.mechanism) { throw new Error("Child ResearchResult scalar fields are not byte-exact copies of the fixture") } @@ -150,13 +164,11 @@ if (parentRead) throw new Error("Parent read the fixture itself, so child isolat // Production guard: unexpected parent tool errors indicate the parent called a denied tool // (e.g., task_status or task_read). Deny decisions do NOT fire permission events so they // won't appear in permissionRequests — this explicit check catches them. -const unexpectedParentErrors = observation.tools.filter( - tool => tool.status === "error" && tool.name !== "task", -) +const unexpectedParentErrors = observation.tools.filter((tool) => tool.status === "error" && tool.name !== "task") if (unexpectedParentErrors.length > 0) { throw new Error( `Parent made ${unexpectedParentErrors.length} unexpected denied tool call(s): ` + - unexpectedParentErrors.map(t => t.name).join(", ") + + unexpectedParentErrors.map((t) => t.name).join(", ") + " — check primaryPermission matches the parent prompt", ) } @@ -168,6 +180,7 @@ const resultArtifact = { childSessionID: child.id, childAssistantTurns: child.assistants.length, structuredOutputCallCount: structuredCalls.length, + finalizerTransport: strictFinalizer ? "structured_tool" : "validated_text", finalizerTurnForeignToolCount: foreignFinalizerTools.length, researchToolNames: childTools.map((tool) => tool.name), parentReadOfFixture: parentRead !== undefined, @@ -192,13 +205,52 @@ function record(value: unknown, name: string): Record { return value as Record } +function extractJson(text: string): unknown { + const trimmed = text.trim() + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() + const objectStart = trimmed.indexOf("{") + const objectEnd = trimmed.lastIndexOf("}") + const candidates = [ + trimmed, + fenced, + objectStart !== -1 && objectEnd > objectStart ? trimmed.slice(objectStart, objectEnd + 1) : undefined, + ].filter((candidate): candidate is string => candidate !== undefined) + for (const candidate of candidates) { + try { + return JSON.parse(candidate) + } catch { + continue + } + } + return undefined +} + +function nestedRecordOptional(value: unknown, keys: string[]) { + return keys.reduce | undefined>( + (current, key) => { + if (!current) return undefined + const next = current[key] + if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined + return next as Record + }, + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined, + ) +} + function nestedRecord(value: unknown, keys: string[]) { - const result = keys.reduce | undefined>((current, key) => { - if (!current) return undefined - const next = current[key] - if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined - return next as Record - }, typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : undefined) + const result = keys.reduce | undefined>( + (current, key) => { + if (!current) return undefined + const next = current[key] + if (typeof next !== "object" || next === null || Array.isArray(next)) return undefined + return next as Record + }, + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined, + ) if (!result) throw new Error(`Missing object path ${keys.join(".")}`) return result } diff --git a/packages/deepagent-code/script/live-llm/plan-advance-contract.ts b/packages/deepagent-code/script/live-llm/plan-advance-contract.ts new file mode 100644 index 000000000..e98c65e23 --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-advance-contract.ts @@ -0,0 +1,226 @@ +import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" +import { loadPlanLiveLLMConfig, writeLiveArtifact } from "../../../llm/script/live-llm/config" +import { finishLiveScript } from "./lifecycle" +import { assertPlanAdvanceObservation } from "./plan-advance-oracle" +import { runLegacyLiveCases } from "./runtime" + +const config = await loadPlanLiveLLMConfig() +const conflictCase = "retry-after-authority-race" +const concurrentNote = "concurrent authority update" +let immutable: PlanDoc | undefined +let conflictInjected = false + +const artifact = await runLegacyLiveCases({ + suite: "plan-advance-contract-legacy", + config, + permission: { "*": "deny" }, + primaryPermission: { "*": "deny", plan: "ask" }, + permissionReply: { reply: "once" }, + sharedSession: true, + inspectDurability: true, + inspectPlan: true, + observeAssembledRequestFingerprints: true, + environment: { DEEPAGENT_ENABLED: "true", DEEPAGENT_MODE: "high" }, + primaryPrompt: [ + "This is a Plan advance parameter-contract test in one durable Session.", + "Call only the plan tool requested by the current user.", + "For operation advance, copy expected_plan_id, expected_version, and step_id values exactly from the latest plan-status or plan result.", + "Send only operation, expected_plan_id, expected_version, active_step_id, and steps containing step_id plus status.", + "Never send goal, assumptions, replan_reason, title, acceptance, or assigned_agent on advance.", + "If the tool returns plan_protocol conflict, retry the same requested transition exactly once from the authoritative parameters in that result.", + ].join(" "), + modelMaxTokens: config.providerID === "kimi" ? 1536 : 768, + maxProviderTurns: 5, + cases: [ + { + name: "advance-first-boundary", + prompt: planPrompt("step_1", "step_2", false), + }, + { + name: "advance-second-boundary", + prompt: planPrompt("step_2", "step_3", false), + }, + { + name: conflictCase, + prompt: planPrompt("step_3", "step_4", true), + }, + ], + beforeCase: async ({ caseName, sessionID }) => { + if (caseName !== "advance-first-boundary") return + AgentGateway.DeepAgentSessionState.getOrCreate(sessionID, "high") + const plan = AgentGateway.DeepAgentPlanController.createPlanDoc( + sessionID, + "Verify model Plan advances preserve server-owned authority", + [ + { + step_id: "step_1", + title: "Inspect the authoritative Plan snapshot", + status: "active", + acceptance: "The model uses the supplied CAS precondition", + assigned_agent: "primary", + }, + { + step_id: "step_2", + title: "Advance with a minimal status patch", + status: "pending", + acceptance: "Server-owned identity remains unchanged", + assigned_agent: "primary", + }, + { + step_id: "step_3", + title: "Recover from an injected authority race", + status: "pending", + acceptance: "The retry uses the returned authoritative baseline", + assigned_agent: "primary", + }, + { + step_id: "step_4", + title: "Retain the concurrent authority update", + status: "pending", + acceptance: "Concurrent server-owned data survives the retry", + assigned_agent: "primary", + }, + ], + ["The Plan document is the only structural authority"], + ) + const committed = AgentGateway.DeepAgentPlanStore.compareAndCommitPlan({ + sessionId: sessionID, + expected: null, + candidate: plan, + origin: "runtime_goal_bridge", + }) + AgentGateway.DeepAgentSessionState.bindPlan(sessionID, committed.plan, null, committed.changed) + immutable = committed.plan + }, + beforePermissionReply: async ({ caseName, request }) => { + if (caseName !== conflictCase || request.permission !== "plan" || conflictInjected) return + const current = AgentGateway.DeepAgentPlanStore.getPlanDoc(request.sessionID) + const ref = AgentGateway.DeepAgentPlanStore.planDocRef(request.sessionID) + if (!current || !ref) throw new Error("Conflict injection could not read the Plan authority") + const committed = AgentGateway.DeepAgentPlanStore.compareAndCommitPlan({ + sessionId: request.sessionID, + expected: { plan_id: current.plan_id, doc_id: ref.id, version: ref.version }, + candidate: { + ...current, + steps: current.steps.map((step) => (step.step_id === "step_3" ? { ...step, note: concurrentNote } : step)), + }, + origin: "runtime_goal_bridge", + }) + AgentGateway.DeepAgentSessionState.bindPlan(request.sessionID, committed.plan, current, committed.changed) + conflictInjected = true + }, +}) + +await writeLiveArtifact(config, `${artifact.suite}-observed`, artifact) + +if (!immutable) throw new Error("Plan contract suite did not seed its authoritative Plan") +const authoritativePlan = immutable +if (!conflictInjected) throw new Error("Plan contract suite did not inject the authority race") +if (artifact.status !== "passed") + throw new Error(`Plan contract Provider run failed: ${JSON.stringify(artifact.error)}`) +if (new Set(artifact.cases.map((testCase) => testCase.sessionID)).size !== 1) { + throw new Error("Plan contract cases did not reuse one durable Session") +} + +const expectations = [ + { + caseName: "advance-first-boundary", + version: 2, + activeStepID: "step_2", + statuses: { step_1: "done", step_2: "active", step_3: "pending", step_4: "pending" }, + notes: { step_1: null, step_2: null, step_3: null, step_4: null }, + calls: [{ version: 1, protocol: "success" as const }], + }, + { + caseName: "advance-second-boundary", + version: 3, + activeStepID: "step_3", + statuses: { step_1: "done", step_2: "done", step_3: "active", step_4: "pending" }, + notes: { step_1: null, step_2: null, step_3: null, step_4: null }, + calls: [{ version: 2, protocol: "success" as const }], + }, + { + caseName: conflictCase, + version: 5, + activeStepID: "step_4", + statuses: { step_1: "done", step_2: "done", step_3: "done", step_4: "active" }, + notes: { step_1: null, step_2: null, step_3: concurrentNote, step_4: null }, + calls: [ + { version: 3, protocol: "conflict" as const }, + { version: 4, protocol: "success" as const }, + ], + }, +] + +expectations.forEach((expected) => { + const observation = artifact.cases.find((testCase) => testCase.name === expected.caseName) + if (!observation) throw new Error(`Missing Plan contract case ${expected.caseName}`) + const statusPatch: Record = + expected.caseName === "advance-first-boundary" + ? { step_1: "done", step_2: "active" } + : expected.caseName === "advance-second-boundary" + ? { step_2: "done", step_3: "active" } + : { step_3: "done", step_4: "active" } + assertPlanAdvanceObservation({ + caseName: expected.caseName, + observation, + immutable: authoritativePlan, + expectedVersion: expected.version, + expectedActiveStepID: expected.activeStepID, + expectedStatuses: expected.statuses, + expectedNotes: expected.notes, + expectedCalls: expected.calls.map((call) => ({ + ...call, + activeStepID: expected.activeStepID, + statuses: statusPatch, + })), + }) + if (observation.providerErrors.length > 0) { + throw new Error(`${expected.caseName} recorded Provider errors: ${JSON.stringify(observation.providerErrors)}`) + } + if (observation.assembledRequestFingerprints.length < expected.calls.length) { + throw new Error(`${expected.caseName} did not capture every Provider request boundary`) + } +}) + +const result = { + ...artifact, + evidence: { + provider: config.providerID, + durableSessionCount: new Set(artifact.cases.map((testCase) => testCase.sessionID)).size, + conflictInjected, + finalPlanVersion: artifact.cases.at(-1)?.plan?.ref?.version, + planCalls: artifact.cases.flatMap((testCase) => + testCase.newTools.map((tool) => ({ + caseName: testCase.name, + name: tool.name, + status: tool.status, + protocol: + typeof tool.metadata === "object" && tool.metadata !== null && "plan_protocol" in tool.metadata + ? tool.metadata.plan_protocol + : undefined, + })), + ), + }, +} +await writeLiveArtifact(config, result.suite, result) +console.log( + `${result.suite}: passed (${result.fingerprint.providerID}/${result.fingerprint.modelID}, ` + + `${result.evidence.planCalls.length} Plan calls, final version ${result.evidence.finalPlanVersion})`, +) + +finishLiveScript() + +function planPrompt(doneStepID: string, activeStepID: string, retryConflict: boolean) { + return [ + `Call plan to mark ${doneStepID} done and ${activeStepID} active.`, + "Use operation advance and copy the exact expected_plan_id and expected_version from the latest plan-status.", + `Set active_step_id to ${activeStepID}. Send exactly two steps: ${doneStepID} with status done, then ${activeStepID} with status active.`, + "Each step object must contain only step_id and status. Omit goal, assumptions, replan_reason, title, acceptance, assigned_agent, and note.", + retryConflict + ? "If the first result is a Plan conflict, retry this same transition exactly once with the authoritative expected_* values returned by the tool." + : "Call plan exactly once. No conflict is expected.", + "Do not call any other tool.", + ].join(" ") +} diff --git a/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts new file mode 100644 index 000000000..c249b82bd --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts @@ -0,0 +1,226 @@ +type PlanStep = { + step_id: string + title: string + status: string + acceptance?: string | null + assigned_agent?: string | null + note?: string | null +} + +type PlanDocument = { + plan_id: string + goal: string + assumptions: readonly string[] + active_step_id: string | null + steps: readonly PlanStep[] +} + +type ToolCall = { + messageID: string + id: string + name: string + status: string + input: unknown + metadata?: unknown +} + +type RequestReceipt = { + receipt_id: string + assistant_message_id: string | null + request_state: string + final_offered_tool_ids: readonly string[] + call_ids: readonly string[] + tool_definition_hash: string | null +} + +type ArgumentReceipt = { + receipt_id: string + layer: string + call_id: string | null + tool_name: string | null + event_type: string + payload_hash: string | null + payload_length: number | null + payload_keys: readonly string[] + unavailable_reason: string | null + validation_outcome: string +} + +export function assertPlanAdvanceObservation(input: { + caseName: string + observation: { + newTools: readonly ToolCall[] + plan?: { document: PlanDocument | null; ref: { id: string; version: number } | null } + durability?: { + requestReceipts: readonly RequestReceipt[] + argumentReceipts: readonly ArgumentReceipt[] + } + } + immutable: PlanDocument + expectedVersion: number + expectedActiveStepID: string | null + expectedStatuses: Readonly> + expectedNotes?: Readonly> + expectedCalls: ReadonlyArray<{ + version: number + protocol: "success" | "conflict" + activeStepID: string | null + statuses: Readonly> + }> +}) { + const calls = input.observation.newTools.filter((tool) => tool.name === "plan") + if (calls.length !== input.expectedCalls.length || input.observation.newTools.length !== calls.length) { + throw new Error( + `${input.caseName} tool sequence mismatch: ${JSON.stringify( + input.observation.newTools.map((tool) => `${tool.name}:${tool.status}`), + )}`, + ) + } + + calls.forEach((call, index) => { + if (call.status !== "completed") throw new Error(`${input.caseName} plan call ${index + 1} did not complete`) + const args = record(call.input, `${input.caseName} plan input ${index + 1}`) + const metadata = record(call.metadata, `${input.caseName} plan metadata ${index + 1}`) + const expected = input.expectedCalls[index]! + if ( + args.operation !== "advance" || + args.expected_plan_id !== input.immutable.plan_id || + args.expected_version !== expected.version + ) { + throw new Error(`${input.caseName} plan precondition mismatch: ${JSON.stringify(args)}`) + } + const allowedKeys = new Set(["operation", "expected_plan_id", "expected_version", "steps", "active_step_id"]) + for (const key of Object.keys(args)) { + if (!allowedKeys.has(key)) throw new Error(`${input.caseName} plan input supplied non-patch field ${key}`) + } + if (args.active_step_id !== expected.activeStepID) { + throw new Error(`${input.caseName} plan call ${index + 1} supplied the wrong active_step_id`) + } + const steps = array(args.steps, `${input.caseName} plan steps ${index + 1}`).map((step) => + record(step, `${input.caseName} plan step ${index + 1}`), + ) + if (steps.length === 0) throw new Error(`${input.caseName} plan call ${index + 1} supplied no status patch`) + for (const step of steps) { + if (typeof step.step_id !== "string" || typeof step.status !== "string") { + throw new Error(`${input.caseName} plan call ${index + 1} omitted step_id/status`) + } + for (const key of Object.keys(step)) { + if (!new Set(["step_id", "status", "note"]).has(key)) { + throw new Error(`${input.caseName} plan input supplied non-patch step field ${key}`) + } + } + } + const statuses = Object.fromEntries(steps.map((step) => [step.step_id, step.status])) + if (JSON.stringify(statuses) !== JSON.stringify(expected.statuses)) { + throw new Error(`${input.caseName} plan call ${index + 1} supplied the wrong status patch`) + } + if (metadata.plan_protocol !== expected.protocol) { + throw new Error( + `${input.caseName} plan call ${index + 1} expected ${expected.protocol}, received ${String(metadata.plan_protocol)}`, + ) + } + assertArgumentReceipts(input.caseName, call, input.observation.durability, expected.protocol) + }) + + const plan = input.observation.plan?.document + const ref = input.observation.plan?.ref + if (!plan || !ref) throw new Error(`${input.caseName} did not capture the durable Plan authority`) + if (ref.version !== input.expectedVersion) { + throw new Error(`${input.caseName} expected Plan version ${input.expectedVersion}, received ${ref.version}`) + } + if ( + plan.plan_id !== input.immutable.plan_id || + plan.goal !== input.immutable.goal || + JSON.stringify(plan.assumptions) !== JSON.stringify(input.immutable.assumptions) || + plan.active_step_id !== input.expectedActiveStepID + ) { + throw new Error(`${input.caseName} changed authoritative Plan identity: ${JSON.stringify(plan)}`) + } + if (plan.steps.length !== input.immutable.steps.length) { + throw new Error(`${input.caseName} changed the authoritative Plan step count`) + } + plan.steps.forEach((step, index) => { + const immutable = input.immutable.steps[index] + if ( + !immutable || + step.step_id !== immutable.step_id || + step.title !== immutable.title || + (step.acceptance ?? null) !== (immutable.acceptance ?? null) || + (step.assigned_agent ?? null) !== (immutable.assigned_agent ?? null) + ) { + throw new Error(`${input.caseName} changed server-owned step identity at index ${index}`) + } + if (step.status !== input.expectedStatuses[step.step_id]) { + throw new Error(`${input.caseName} unexpected status for ${step.step_id}: ${step.status}`) + } + if (input.expectedNotes && (step.note ?? null) !== (input.expectedNotes[step.step_id] ?? null)) { + throw new Error(`${input.caseName} unexpected note for ${step.step_id}: ${String(step.note)}`) + } + }) +} + +function assertArgumentReceipts( + caseName: string, + call: ToolCall, + durability: + | { + requestReceipts: readonly RequestReceipt[] + argumentReceipts: readonly ArgumentReceipt[] + } + | undefined, + protocol: "success" | "conflict", +) { + if (!durability) throw new Error(`${caseName} did not capture request/argument receipts`) + const request = durability.requestReceipts.find( + (receipt) => receipt.assistant_message_id === call.messageID && receipt.call_ids.includes(call.id), + ) + if ( + !request || + request.request_state !== "dispatched" || + !request.final_offered_tool_ids.includes("plan") || + !request.tool_definition_hash + ) { + throw new Error(`${caseName} request receipt was incomplete: ${JSON.stringify(request)}`) + } + const receipts = durability.argumentReceipts.filter( + (receipt) => receipt.receipt_id === request.receipt_id && receipt.call_id === call.id, + ) + const aiSdkInput = receipts.find((receipt) => receipt.layer === "ai_sdk_input") + const adapter = receipts.find((receipt) => receipt.layer === "adapter_assembly" && receipt.event_type === "tool-call") + const decoded = receipts.find((receipt) => receipt.layer === "processor_decoded") + const rawFrame = durability.argumentReceipts.find( + (receipt) => receipt.receipt_id === request.receipt_id && receipt.layer === "raw_frame", + ) + if ( + !aiSdkInput?.payload_hash || + !adapter?.payload_hash || + !decoded?.payload_hash || + aiSdkInput.tool_name !== "plan" || + adapter.tool_name !== "plan" || + decoded.tool_name !== "plan" || + adapter.payload_hash !== decoded.payload_hash || + adapter.payload_length !== decoded.payload_length || + JSON.stringify(adapter.payload_keys) !== JSON.stringify(decoded.payload_keys) || + aiSdkInput.validation_outcome !== "schema_valid" || + adapter.validation_outcome !== "schema_valid" || + decoded.validation_outcome !== (protocol === "success" ? "semantic_valid" : "conflict") + ) { + throw new Error(`${caseName} argument receipt chain was incomplete: ${JSON.stringify(receipts)}`) + } + if ( + !rawFrame || + (rawFrame.payload_hash == null && rawFrame.unavailable_reason !== "provider_transport_did_not_expose_raw_frame") + ) { + throw new Error(`${caseName} raw-frame provenance was neither captured nor explicitly unavailable`) + } +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} is not an object`) + return value as Record +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} is not an array`) + return value +} diff --git a/packages/deepagent-code/script/live-llm/routes.ts b/packages/deepagent-code/script/live-llm/routes.ts index 4c48e5e6d..7ce669b22 100644 --- a/packages/deepagent-code/script/live-llm/routes.ts +++ b/packages/deepagent-code/script/live-llm/routes.ts @@ -47,6 +47,7 @@ export const modelSuites = [ "intelligence-draft-confirmation", "prompt-intent-fencing", "subagent-control-plane", + "plan-advance-contract", ] as const export type ExecutionStack = (typeof executionStacks)[number] @@ -119,6 +120,7 @@ const goalGraderCliEntry = modelRun("ext", "cli-subprocess", "goal-grader-cli-en const intelligenceDraft = modelRun("ext", "legacy-session", "intelligence-draft-confirmation") const promptIntentFencing = modelRun("ext", "legacy-session", "prompt-intent-fencing") const subagentControlPlane = modelRun("live", "legacy-session", "subagent-control-plane") +const planAdvanceContract = modelRun("live", "legacy-session", "plan-advance-contract") const allHarnessRuns = [ adapterProvider, cliHeadless, @@ -156,6 +158,7 @@ const allHarnessRuns = [ intelligenceDraft, promptIntentFencing, subagentControlPlane, + planAdvanceContract, ] export const routeManifest = [ @@ -269,6 +272,15 @@ export const routeManifest = [ checks: ["session-continuation"], runs: [continuationRepetition], }, + { + id: "live-llm-plan-advance-contract-harness", + paths: [ + "packages/deepagent-code/script/live-llm/plan-advance-contract.ts", + "packages/deepagent-code/script/live-llm/plan-advance-oracle.ts", + ], + checks: ["live-llm-routes", "llm-adapter"], + runs: [planAdvanceContract], + }, { id: "live-llm-degeneration-harness", paths: ["packages/deepagent-code/script/live-llm/degeneration.ts"], @@ -575,6 +587,18 @@ export const routeManifest = [ checks: ["live-llm-routes", "session-continuation"], runs: [continuationRepetition], }, + { + id: "plan-advance-contract-production", + paths: [ + "packages/core/src/deepagent/plan-controller.ts", + "packages/core/src/deepagent/prompt-policy.ts", + "packages/deepagent-code/src/session/llm/request.ts", + "packages/deepagent-code/src/session/reminders.ts", + "packages/deepagent-code/src/tool/plan*.{ts,txt}", + ], + checks: ["live-llm-routes", "llm-adapter", "permission"], + runs: [planAdvanceContract], + }, { id: "legacy-session-prompt", paths: [ diff --git a/packages/deepagent-code/script/live-llm/runtime.ts b/packages/deepagent-code/script/live-llm/runtime.ts index 8a5213531..916075ba9 100644 --- a/packages/deepagent-code/script/live-llm/runtime.ts +++ b/packages/deepagent-code/script/live-llm/runtime.ts @@ -19,6 +19,10 @@ import { prepareToolSandbox, type ToolSandbox } from "../../../core/script/live- export const runtimeProviderID = "live-deepseek" +export function runtimeProviderIDFor(config: Pick) { + return config.providerID === "deepseek" ? runtimeProviderID : "live-kimi" +} + export async function directoryExists(directory: string): Promise { try { return (await stat(directory)).isDirectory() @@ -132,6 +136,7 @@ export type V4LiveEventCase = { export async function runLegacyLiveCases(input: { suite: string + config?: LiveLLMConfig cases: LegacyLiveCase[] permission: ConfigV1.Info["permission"] primaryPermission?: ConfigV1.Info["permission"] @@ -155,7 +160,17 @@ export async function runLegacyLiveCases(input: { maxProviderTurns?: number toolOutput?: ConfigV1.Info["tool_output"] evaluateWorkspace?: (directory: string, sandbox?: ToolSandbox) => Promise - beforeCase?: (input: { caseName: string; directory: string; sandbox?: ToolSandbox }) => Promise + beforeCase?: (input: { + caseName: string + directory: string + sessionID: string + sandbox?: ToolSandbox + }) => Promise + beforePermissionReply?: (input: { + caseName?: string + directory: string + request: PermissionV1.Request + }) => Promise sharedSession?: boolean compactAfterCases?: string[] timeoutMs?: number @@ -165,12 +180,14 @@ export async function runLegacyLiveCases(input: { steerDuringCases?: ReadonlyArray<{ duringCaseName: string; text: string }> observeAssembledRequestFingerprints?: boolean inspectDurability?: boolean + inspectPlan?: boolean subagentIntensity?: "inherit" | "downgrade" environment?: Readonly> panel?: LegacyPanelCase v4Event?: V4LiveEventCase }) { - const config = await loadLiveLLMConfig() + const config = input.config ?? (await loadLiveLLMConfig()) + const liveProviderID = runtimeProviderIDFor(config) if ( input.permissionBarrierCount !== undefined && (!Number.isSafeInteger(input.permissionBarrierCount) || input.permissionBarrierCount < 2) @@ -196,6 +213,7 @@ export async function runLegacyLiveCases(input: { await prepareIsolation(testRoot, isolatedHome, isolatedData, config, input.environment) const { ModelV2 } = await import("@deepagent-code/core/model") const { ProviderV2 } = await import("@deepagent-code/core/provider") + const { AgentGateway } = await import("@deepagent-code/core/agent-gateway") const { CrossSpawnSpawner } = await import("@deepagent-code/core/cross-spawn-spawner") const { EffectFlock } = await import("@deepagent-code/core/util/effect-flock") const { Context, Deferred, Effect, Fiber, Layer, Schedule } = await import("effect") @@ -225,6 +243,7 @@ export async function runLegacyLiveCases(input: { const { MessageID } = await import("../../src/session/schema") const { SessionSteer } = await import("../../src/session/steer") const { Session } = await import("../../src/session/session") + const { SessionToolArgumentReceiptTable } = await import("../../src/session/tool-argument-receipt.sql") const { SessionToolRequestReceiptTable } = await import("../../src/session/tool-request-receipt.sql") const { SessionIntentTable } = await import("@deepagent-code/core/session/sql") const { EventDispatcher } = await import("../../src/session/event-dispatcher") @@ -239,7 +258,7 @@ export async function runLegacyLiveCases(input: { const { makeTaskSubagentRunner } = await import("../../src/session/goal-loop-wiring") const { TestInstance, testInstanceStoreLayer, tmpdirScoped } = await import("../../test/fixture/fixture") - const providerID = ProviderV2.ID.make(runtimeProviderID) + const providerID = ProviderV2.ID.make(liveProviderID) const modelID = ModelV2.ID.make(config.modelID) const startedAt = Date.now() let sandbox: ToolSandbox | undefined @@ -267,6 +286,7 @@ export async function runLegacyLiveCases(input: { const instances = input.v4Event ? yield* InstanceStore.Service : undefined const gitService = input.v4Event ? yield* Git.Service : undefined const prQueue = input.v4Event ? yield* PRQueue.Service : undefined + let activeCaseName: string | undefined const assembledRequestFingerprints: GlobalEvent[] = [] const requestFingerprintListener = (event: GlobalEvent) => { if (event.payload?.type !== "session.request.assembled-fingerprint") return @@ -297,6 +317,15 @@ export async function runLegacyLiveCases(input: { workspaceID: event.location?.workspaceID, }) return Effect.gen(function* () { + if (input.beforePermissionReply) { + yield* Effect.promise(() => + input.beforePermissionReply!({ + caseName: activeCaseName, + directory: instance.directory, + request, + }), + ) + } if (permissionBarrier && input.permissionBarrierCount) { if (permissionRequests.length === input.permissionBarrierCount) { permissionBarrierSnapshots.push( @@ -576,9 +605,15 @@ export async function runLegacyLiveCases(input: { const observations = yield* Effect.forEach(input.cases, (testCase) => Effect.gen(function* () { const session = sharedSession ?? (yield* sessions.create({ title: `Live ${input.suite}: ${testCase.name}` })) + activeCaseName = testCase.name if (input.beforeCase) { yield* Effect.promise(() => - input.beforeCase!({ caseName: testCase.name, directory: instance.directory, sandbox }), + input.beforeCase!({ + caseName: testCase.name, + directory: instance.directory, + sessionID: session.id, + sandbox, + }), ) } const revertEvidence = testCase.revertBefore @@ -835,7 +870,7 @@ export async function runLegacyLiveCases(input: { const panelCase = input.panel?.afterCaseName === testCase.name ? input.panel : undefined if (panelCase && sharedSession && agents) { const opinions: unknown[] = [] - const model = { providerID: runtimeProviderID, modelID: config.modelID } + const model = { providerID: liveProviderID, modelID: config.modelID } const runTurn = makeTaskSubagentRunner({ sessions, agents, @@ -1046,35 +1081,52 @@ export async function runLegacyLiveCases(input: { .pipe(Effect.orDie) : undefined const durability = input.inspectDurability - ? { - promptEpochs: yield* database.db - .select() - .from(SessionPromptEpochTable) - .where(eq(SessionPromptEpochTable.session_id, session.id)) - .all() - .pipe(Effect.orDie), - compactionRuns: yield* database.db - .select() - .from(CompactionRunTable) - .where(eq(CompactionRunTable.session_id, session.id)) - .all() - .pipe(Effect.orDie), - summaryAttempts: yield* database.db - .select() - .from(CompactionSummaryAttemptTable) - .all() - .pipe(Effect.orDie), - requestReceipts: yield* database.db + ? yield* Effect.gen(function* () { + const requestReceipts = yield* database.db .select() .from(SessionToolRequestReceiptTable) .where(eq(SessionToolRequestReceiptTable.session_id, session.id)) .all() - .pipe(Effect.orDie), + .pipe(Effect.orDie) + const receiptIDs = new Set(requestReceipts.map((receipt) => receipt.receipt_id)) + return { + promptEpochs: yield* database.db + .select() + .from(SessionPromptEpochTable) + .where(eq(SessionPromptEpochTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + compactionRuns: yield* database.db + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + summaryAttempts: yield* database.db + .select() + .from(CompactionSummaryAttemptTable) + .all() + .pipe(Effect.orDie), + requestReceipts, + argumentReceipts: (yield* database.db + .select() + .from(SessionToolArgumentReceiptTable) + .all() + .pipe(Effect.orDie)).filter((receipt) => receiptIDs.has(receipt.receipt_id)), + } + }) + : undefined + const plan = input.inspectPlan + ? { + document: AgentGateway.DeepAgentPlanStore.getPlanDoc(session.id), + ref: AgentGateway.DeepAgentPlanStore.planDocRef(session.id), + root: AgentGateway.DeepAgentPlanStore.planStoreRoot(session.id), } : undefined return { name: testCase.name, sessionID: session.id, + plan, assembledRequestFingerprints: assembledRequestFingerprints .slice(requestFingerprintCountBefore) .filter((event) => event.payload?.properties?.sessionID === session.id) @@ -1361,7 +1413,7 @@ export async function runLegacyLiveCases(input: { stack: input.v4Event ? ("v4-event-runtime" as const) : ("legacy-session" as const), status: errors.length > 0 ? ("failed" as const) : ("passed" as const), error: errors.length > 0 ? errors : undefined, - fingerprint: { ...modelFingerprint(config), runtimeProviderID }, + fingerprint: { ...modelFingerprint(config), runtimeProviderID: liveProviderID }, preflight: { durationMs: preflight.durationMs }, sandbox: sandbox?.evidence, initialVerifier, @@ -1479,10 +1531,11 @@ export function liveWorkspaceConfig( subagentIntensity?: "inherit" | "downgrade" }, ): ConfigV1.Info { + const liveProviderID = runtimeProviderIDFor(config) return { snapshot: false, - enabled_providers: [runtimeProviderID], - model: `${runtimeProviderID}/${config.modelID}`, + enabled_providers: [liveProviderID], + model: `${liveProviderID}/${config.modelID}`, permission, mcp, tool_output: options?.toolOutput, @@ -1512,8 +1565,8 @@ export function liveWorkspaceConfig( }, } : {}), - [runtimeProviderID]: { - name: "DeepSeek legacy live test", + [liveProviderID]: { + name: `${config.providerID === "deepseek" ? "DeepSeek" : "Kimi"} legacy live test`, env: [], npm: "@ai-sdk/openai-compatible", api: config.baseURL, @@ -1526,15 +1579,18 @@ export function liveWorkspaceConfig( models: { [config.modelID]: { id: config.modelID, - name: "DeepSeek V4 Flash live test", - reasoning: false, - temperature: true, + name: `${config.modelID} live test`, + reasoning: config.providerID === "kimi", + temperature: config.providerID === "deepseek", tool_call: true, release_date: "2026-07-27", limit: { context: options?.modelContextTokens ?? 1_000_000, output: 2048 }, cost: { input: 0, output: 0 }, modalities: { input: ["text"], output: ["text"] }, - options: { thinking: { type: "disabled" }, maxTokens: options?.modelMaxTokens ?? 512, temperature: 0 }, + options: + config.providerID === "deepseek" + ? { thinking: { type: "disabled" }, maxTokens: options?.modelMaxTokens ?? 512, temperature: 0 } + : { reasoningEffort: "low", maxTokens: options?.modelMaxTokens ?? 1024 }, }, }, }, diff --git a/packages/deepagent-code/src/session/compaction.ts b/packages/deepagent-code/src/session/compaction.ts index 64f2c2bf8..9e7780aad 100644 --- a/packages/deepagent-code/src/session/compaction.ts +++ b/packages/deepagent-code/src/session/compaction.ts @@ -12,10 +12,15 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { Database } from "@deepagent-code/core/database/database" -import { MessageTable, PartTable } from "@deepagent-code/core/session/sql" +import { MessageTable, PartTable, SessionTable, SessionWorldStateBaselineTable } from "@deepagent-code/core/session/sql" import { PromptEpoch } from "./prompt-epoch" -import { CompactionRunTable, CompactionSummaryAttemptTable, type SummaryAttemptState } from "./compaction-sql" -import { eq, and, inArray } from "drizzle-orm" +import { + CompactionArtifactTable, + CompactionRunTable, + CompactionSummaryAttemptTable, + type SummaryAttemptState, +} from "./compaction-sql" +import { eq, and, inArray, isNull } from "drizzle-orm" import { Cause, Effect, Exit, Layer, Context, Option } from "effect" import * as DateTime from "effect/DateTime" @@ -30,15 +35,24 @@ import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" import { EventV2 } from "@deepagent-code/core/event" import { buildPrompt } from "@deepagent-code/core/session/compaction" -import { updateLedgerFromSummary, carryOverToBridge } from "./context-ledger" +import { + updateLedgerFromSummaryRequired, + carryOverToBridgeRequired, + collectSessionWorldStateBaseline, + type SessionWorldStateBaseline, +} from "./context-ledger" import { Hash } from "@deepagent-code/core/util/hash" import { LLM } from "./llm" +import { HistoryAuthority } from "./history-authority" +import { Identifier } from "@/id/id" +import { Project } from "@deepagent-code/core/project" const log = Log.create({ service: "session.compaction" }) export const Event = { Compacted: EventV2.define({ type: "session.compacted", + sync: { aggregate: "sessionID", version: 1 }, schema: { sessionID: SessionID, }, @@ -122,31 +136,6 @@ function turns(messages: SessionV1.WithParts[]) { return result } -function splitTurn(input: { - messages: SessionV1.WithParts[] - turn: Turn - model: Provider.Model - budget: number - estimate: (input: { messages: SessionV1.WithParts[]; model: Provider.Model }) => Effect.Effect -}) { - return Effect.gen(function* () { - if (input.budget <= 0) return undefined - if (input.turn.end - input.turn.start <= 1) return undefined - for (let start = input.turn.start + 1; start < input.turn.end; start++) { - const size = yield* input.estimate({ - messages: input.messages.slice(start, input.turn.end), - model: input.model, - }) - if (size > input.budget) continue - return { - start, - id: input.messages[start]!.info.id, - } satisfies Tail - } - return undefined - }) -} - export interface Interface { readonly isOverflow: (input: { tokens: SessionV1.Assistant["tokens"] @@ -166,9 +155,73 @@ export interface Interface { model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } auto: boolean overflow?: boolean + activityID?: string }) => Effect.Effect + readonly recover: (sessionID: SessionID) => Effect.Effect + readonly recoverableContinuations: (projectID: Project.ID) => Effect.Effect< + readonly { + runID: string + sessionID: SessionID + messageID: MessageID + }[] + > + readonly hasPending: (sessionID: SessionID) => Effect.Effect } +export const validateReplacementTargetInTransaction = Effect.fn( + "SessionCompaction.validateReplacementTargetInTransaction", +)(function* (input: { + tx: Database.Interface["db"] + sessionID: SessionID + replacementMessageIDs: readonly MessageID[] + checkpointUserID: MessageID + checkpointAssistantID: MessageID + markerMessageID: MessageID + markerPartID: PartID + retainedTailStartID?: MessageID + contextTokens: number + checkpointHash: string + effectiveHistoryHash: string +}) { + const replacement = yield* MessageV2.messagesInTransaction(input.tx, input.sessionID, input.replacementMessageIDs) + if (!replacement) return false + const checkpointUser = replacement.find((message) => message.info.id === input.checkpointUserID) + const checkpointAssistant = replacement.find((message) => message.info.id === input.checkpointAssistantID) + if ( + checkpointUser?.info.role !== "user" || + checkpointAssistant?.info.role !== "assistant" || + checkpointAssistant.info.parentID !== checkpointUser.info.id || + !checkpointAssistant.info.summary || + !checkpointAssistant.info.finish || + checkpointAssistant.info.error + ) + return false + const target = replacement.map((message) => { + if (message.info.id !== input.markerMessageID) return message + const markerPart = message.parts.find( + (part): part is SessionV1.CompactionPart => part.id === input.markerPartID && part.type === "compaction", + ) + if (!markerPart) return message + return { + info: message.info, + parts: message.parts.map((part) => + part.id === markerPart.id + ? { + ...markerPart, + tail_start_id: input.retainedTailStartID, + context_tokens: input.contextTokens, + } + : part, + ), + } + }) + return ( + target.some((message) => message.parts.some((part) => part.id === input.markerPartID)) && + HistoryAuthority.hash(target) === input.effectiveHistoryHash && + input.checkpointHash === input.effectiveHistoryHash + ) +}) + export class Service extends Context.Service()("@deepagent-code/SessionCompaction") {} export const use = serviceUse(Service) @@ -186,8 +239,218 @@ export const layer = Layer.effect( const flags = yield* RuntimeFlags.Service const { db } = yield* Database.Service const promptEpoch = yield* PromptEpoch.Service + const activeCompactions = new Set() + + const registerArtifact = (input: { + runID: string + sessionID: SessionID + messageID: MessageID + partID?: PartID + kind: typeof CompactionArtifactTable.$inferInsert.kind + }) => + db + .insert(CompactionArtifactTable) + .values({ + artifact_id: Hash.sha256( + `compaction-artifact:v1:${input.runID}:${input.kind}:${input.messageID}:${input.partID ?? "message"}`, + ), + run_id: input.runID, + session_id: input.sessionID, + message_id: input.messageID, + part_id: input.partID ?? null, + kind: input.kind, + state: "pending", + created_at: Date.now(), + committed_at: null, + published_at: null, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + + const publishCommittedRun = Effect.fn("SessionCompaction.publishCommittedRun")(function* (runID: string) { + const run = yield* db + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.run_id, runID)) + .get() + .pipe(Effect.orDie) + if (!run || run.state !== "committed") return + + const artifacts = yield* db + .select() + .from(CompactionArtifactTable) + // Older builds may have committed replay artifacts. They remain publishable for recovery, + // but current compaction runs only create synthetic continuation artifacts. + .where( + and( + eq(CompactionArtifactTable.run_id, runID), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["marker", "replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + for (const artifact of artifacts) { + if (artifact.published_at) continue + const message = yield* MessageV2.get({ + sessionID: SessionID.make(run.session_id), + messageID: MessageID.make(artifact.message_id), + }).pipe(Effect.provideService(Database.Service, { db }), Effect.orDie) + const parts = message.parts.filter((part) => !artifact.part_id || artifact.part_id === part.id) + if (artifact.kind === "replay" || artifact.kind === "continue") { + const messageEventID = EventV2.ID.make( + `evt_${Hash.sha256(`compaction-artifact-event:v1:${runID}:message:${message.info.id}`).slice(0, 26)}`, + ) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: message.info.sessionID, info: message.info }, + { + id: messageEventID, + idempotent: true, + ...(parts.length === 0 + ? { + commit: () => + db + .update(CompactionArtifactTable) + .set({ published_at: Date.now() }) + .where( + and( + eq(CompactionArtifactTable.artifact_id, artifact.artifact_id), + isNull(CompactionArtifactTable.published_at), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + } + : {}), + }, + ) + } + for (const [index, part] of parts.entries()) { + const partEventID = EventV2.ID.make( + `evt_${Hash.sha256(`compaction-artifact-event:v1:${runID}:part:${part.id}`).slice(0, 26)}`, + ) + yield* events.publish( + SessionV1.Event.PartUpdated, + { + sessionID: part.sessionID, + part, + time: part.type === "text" ? (part.time?.start ?? message.info.time.created) : message.info.time.created, + }, + { + id: partEventID, + idempotent: true, + ...(index === parts.length - 1 + ? { + commit: () => + db + .update(CompactionArtifactTable) + .set({ published_at: Date.now() }) + .where( + and( + eq(CompactionArtifactTable.artifact_id, artifact.artifact_id), + isNull(CompactionArtifactTable.published_at), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + } + : {}), + }, + ) + } + if (artifact.kind === "marker" && parts.length === 0) + return yield* Effect.die(new Error(`compaction marker artifact is incomplete: ${artifact.artifact_id}`)) + } + yield* db + .update(CompactionRunTable) + .set({ continuation_published_at: Date.now() }) + .where(eq(CompactionRunTable.run_id, runID)) + .run() + .pipe(Effect.orDie) + + if (run.context_ledger_required && run.summary_text) { + if (!run.ledger_mirrored_at) { + yield* updateLedgerFromSummaryRequired({ + sessionID: SessionID.make(run.session_id), + summary: run.summary_text, + operationID: run.run_id, + }) + yield* db + .update(CompactionRunTable) + .set({ ledger_mirrored_at: Date.now() }) + .where(and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.ledger_mirrored_at))) + .run() + .pipe(Effect.orDie) + } + if (!run.bridge_carried_at) { + const owner = yield* db + .select({ directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.id, SessionID.make(run.session_id))) + .get() + .pipe(Effect.orDie) + if (!owner) return yield* Effect.die(new Error(`compaction session is missing: ${run.session_id}`)) + yield* carryOverToBridgeRequired({ + sessionID: SessionID.make(run.session_id), + workspacePath: owner.directory, + }) + yield* db + .update(CompactionRunTable) + .set({ bridge_carried_at: Date.now() }) + .where(and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.bridge_carried_at))) + .run() + .pipe(Effect.orDie) + } + } + + if (!run.terminal_events_published_at && run.summary_text && run.marker_message_id && run.completion_reason) { + if (flags.experimentalEventSystem) { + const endedID = EventV2.ID.make(`evt_${Hash.sha256(`compaction-ended:v1:${runID}`).slice(0, 26)}`) + yield* events.publish( + SessionEvent.Compaction.Ended, + { + sessionID: SessionID.make(run.session_id), + messageID: SessionMessage.ID.make(run.marker_message_id), + timestamp: DateTime.makeUnsafe(run.committed_at ?? Date.now()), + reason: run.completion_reason, + text: run.summary_text, + recent: run.recent_context ?? "", + }, + { id: endedID, idempotent: true }, + ) + } + const compactedID = EventV2.ID.make(`evt_${Hash.sha256(`compaction-completed:v1:${runID}`).slice(0, 26)}`) + yield* events.publish( + Event.Compacted, + { sessionID: SessionID.make(run.session_id) }, + { + id: compactedID, + idempotent: true, + commit: () => + db + .update(CompactionRunTable) + .set({ terminal_events_published_at: Date.now() }) + .where( + and(eq(CompactionRunTable.run_id, runID), isNull(CompactionRunTable.terminal_events_published_at)), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid), + }, + ) + } + }) const recover = Effect.fn("SessionCompaction.recover")(function* (sessionID: SessionID) { + const committed = yield* db + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "committed"))) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach(committed, (run) => publishCommittedRun(run.run_id), { discard: true }) + if (activeCompactions.has(sessionID)) return const requested = yield* db .select({ run_id: CompactionRunTable.run_id, @@ -229,36 +492,61 @@ export const layer = Layer.effect( .pipe(Effect.orDie) : undefined if (markerPart?.data.type === "compaction") return - yield* db - .update(CompactionRunTable) - .set({ state: "failed", terminal_failure_kind: "marker_write_incomplete" }) - .where(and(eq(CompactionRunTable.run_id, run.run_id), eq(CompactionRunTable.state, "requested"))) - .run() - .pipe(Effect.orDie) + yield* failRun(run.run_id, "marker_write_incomplete") }), ) yield* db - .update(CompactionSummaryAttemptTable) - .set({ state: "indeterminate_after_crash", failure_kind: "process_restart", completed_at: Date.now() }) - .where( - and( - inArray( - CompactionSummaryAttemptTable.run_id, - db + .transaction( + (tx) => + Effect.gen(function* () { + const sessionRuns = tx .select({ run_id: CompactionRunTable.run_id }) .from(CompactionRunTable) - .where(eq(CompactionRunTable.session_id, sessionID)), - ), - inArray(CompactionSummaryAttemptTable.state, ["dispatching", "streaming"] as const), - ), + .where(eq(CompactionRunTable.session_id, sessionID)) + yield* tx + .update(CompactionSummaryAttemptTable) + .set({ + state: "indeterminate_after_crash", + failure_kind: "process_restart", + completed_at: Date.now(), + }) + .where( + and( + inArray(CompactionSummaryAttemptTable.run_id, sessionRuns), + inArray(CompactionSummaryAttemptTable.state, ["dispatching", "streaming"] as const), + ), + ) + .run() + yield* tx + .update(CompactionRunTable) + .set({ state: "indeterminate", terminal_failure_kind: "process_restart" }) + .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "summarizing"))) + .run() + yield* tx + .update(CompactionArtifactTable) + .set({ state: "orphaned" }) + .where( + and( + eq(CompactionArtifactTable.session_id, sessionID), + eq(CompactionArtifactTable.state, "pending"), + inArray( + CompactionArtifactTable.run_id, + tx + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + inArray(CompactionRunTable.state, ["failed", "indeterminate"] as const), + ), + ), + ), + ), + ) + .run() + }), + { behavior: "immediate" }, ) - .run() - .pipe(Effect.orDie) - yield* db - .update(CompactionRunTable) - .set({ state: "indeterminate", terminal_failure_kind: "process_restart" }) - .where(and(eq(CompactionRunTable.session_id, sessionID), eq(CompactionRunTable.state, "summarizing"))) - .run() .pipe(Effect.orDie) }) @@ -268,6 +556,10 @@ export const layer = Layer.effect( markerPartID?: PartID fromEpoch: number trigger: "turn_start" | "provider_overflow" | "manual" + sourceWindowID: string + sourceEffectiveHistoryHash: string + sourceMessageCount: number + sourceProjectionVersion: number }) { const existing = yield* db .select() @@ -275,22 +567,37 @@ export const layer = Layer.effect( .where( and( eq(CompactionRunTable.session_id, input.sessionID), - inArray(CompactionRunTable.state, ["requested", "summarizing", "indeterminate"] as const), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), ), ) .get() .pipe(Effect.orDie) if (existing) { if (existing.marker_message_id !== input.markerMessageID) return undefined + if ( + existing.from_prompt_epoch !== input.fromEpoch || + existing.source_window_id !== input.sourceWindowID || + existing.source_effective_history_hash !== input.sourceEffectiveHistoryHash || + existing.source_message_count !== input.sourceMessageCount || + existing.source_projection_version !== input.sourceProjectionVersion + ) + return undefined return existing } const row = { - run_id: Hash.sha256(`compaction-run:${input.sessionID}:${input.markerMessageID}`), + run_id: Hash.sha256( + `compaction-run:v2:${input.sessionID}:${input.markerMessageID}:${Identifier.ascending("job")}`, + ), session_id: input.sessionID, from_prompt_epoch: input.fromEpoch, trigger: input.trigger, marker_message_id: input.markerMessageID, marker_part_id: input.markerPartID, + source_window_id: input.sourceWindowID, + source_effective_history_hash: input.sourceEffectiveHistoryHash, + source_message_count: input.sourceMessageCount, + source_projection_version: input.sourceProjectionVersion, + context_ledger_required: flags.experimentalContextLedger, state: "requested" as const, created_at: Date.now(), } @@ -405,31 +712,87 @@ export const layer = Layer.effect( const failRun = (runID: string, kind: string) => db - .update(CompactionRunTable) - .set({ state: "failed", terminal_failure_kind: kind }) - .where( - and( - eq(CompactionRunTable.run_id, runID), - inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), - ), + .transaction( + (tx) => + Effect.gen(function* () { + yield* tx + .update(CompactionRunTable) + .set({ state: "failed", terminal_failure_kind: kind }) + .where( + and( + eq(CompactionRunTable.run_id, runID), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), + ), + ) + .run() + yield* tx + .update(CompactionArtifactTable) + .set({ state: "orphaned" }) + .where(and(eq(CompactionArtifactTable.run_id, runID), eq(CompactionArtifactTable.state, "pending"))) + .run() + }), + { behavior: "immediate" }, ) - .run() .pipe(Effect.orDie) const commitRun = Effect.fn("SessionCompaction.commitRun")(function* (input: { runID: string sessionID: SessionID fromEpoch: number + markerMessageID: MessageID + markerPartID: PartID checkpointUserID: MessageID checkpointAssistantID: MessageID retainedTailStartID?: MessageID sourceEndMessageID?: MessageID checkpointHash: string + baseMessageCount: number + effectiveHistoryHash: string + replacementMessageIDs: readonly MessageID[] + contextTokens: number + summary: string + recent: string + reason: "auto" | "manual" + worldStateBaseline: SessionWorldStateBaseline + continuation?: { + readonly message: SessionV1.WithParts + readonly kind: "continue" + } }) { return yield* db .transaction( (tx) => Effect.gen(function* () { + const run = yield* tx + .select() + .from(CompactionRunTable) + .where(eq(CompactionRunTable.run_id, input.runID)) + .get() + if (!run || run.state !== "summarizing") return false + const currentSource = yield* MessageV2.promptHistoryProjectionInTransaction( + tx as unknown as Database.Interface["db"], + input.sessionID, + input.markerMessageID, + ) + if ( + !currentSource || + currentSource.epoch !== run.from_prompt_epoch || + currentSource.window.windowID !== run.source_window_id || + currentSource.effectiveHistoryHash !== run.source_effective_history_hash || + currentSource.messages.length !== run.source_message_count || + currentSource.projectionVersion !== run.source_projection_version + ) + return false + // The summary/checkpoint is assembled outside this transaction because it may require + // provider and filesystem work. Re-hydrate and hash the durable target under the commit + // lock so a concurrent Part mutation cannot legalize a stale PromptEpoch. + if ( + !(yield* validateReplacementTargetInTransaction({ + tx: tx as unknown as Database.Interface["db"], + ...input, + })) + ) + return false const settled = yield* tx .select({ id: CompactionSummaryAttemptTable.summary_attempt_id }) .from(CompactionSummaryAttemptTable) @@ -441,8 +804,58 @@ export const layer = Layer.effect( ) .get() if (!settled) return false - const epoch = yield* PromptEpoch.activateInTransaction(tx, input) + const epoch = yield* PromptEpoch.activateInTransaction(tx, { + ...input, + worldStateBaselineHash: input.worldStateBaseline.hash, + }) if (!epoch) return false + yield* tx + .insert(SessionWorldStateBaselineTable) + .values( + input.worldStateBaseline.sections.map((section) => ({ + session_id: input.sessionID, + prompt_epoch: epoch.epoch, + section_id: section.sectionID, + snapshot: section.snapshot, + fragment: section.fragment, + fragment_hash: section.fragmentHash, + provenance: "native" as const, + created_at: epoch.created_at, + })), + ) + .run() + const marker = yield* tx + .select({ data: PartTable.data }) + .from(PartTable) + .where( + and( + eq(PartTable.id, input.markerPartID), + eq(PartTable.message_id, input.markerMessageID), + eq(PartTable.session_id, input.sessionID), + ), + ) + .get() + if (!marker || marker.data.type !== "compaction") { + return yield* Effect.die(new Error(`compaction marker missing during commit: ${input.runID}`)) + } + yield* tx + .update(PartTable) + .set({ + data: { + ...marker.data, + tail_start_id: input.retainedTailStartID, + context_tokens: input.contextTokens, + } as typeof PartTable.$inferInsert.data, + provenance: { + source: "compaction_marker", + owner_session_id: input.sessionID, + owner_prompt_epoch: epoch.epoch, + owner_run_id: input.runID, + durable: true, + }, + }) + .where(eq(PartTable.id, input.markerPartID)) + .run() const committed = yield* tx .update(CompactionRunTable) .set({ @@ -450,12 +863,80 @@ export const layer = Layer.effect( committed_summary_message_id: input.checkpointAssistantID, checkpoint_hash: input.checkpointHash, target_prompt_epoch: epoch.epoch, + summary_text: input.summary, + recent_context: input.recent, + completion_reason: input.reason, committed_at: Date.now(), + continuation_state: input.continuation ? "pending" : null, }) .where(and(eq(CompactionRunTable.run_id, input.runID), eq(CompactionRunTable.state, "summarizing"))) .returning({ run_id: CompactionRunTable.run_id }) .get() if (!committed) return yield* Effect.die(new Error(`compaction commit CAS lost: ${input.runID}`)) + const continuation = input.continuation + if (continuation) { + const committedAt = Date.now() + yield* tx + .insert(MessageTable) + .values({ + id: continuation.message.info.id, + session_id: continuation.message.info.sessionID, + time_created: continuation.message.info.time.created, + time_updated: continuation.message.info.time.created, + data: Object.fromEntries( + Object.entries(continuation.message.info).filter(([key]) => key !== "id" && key !== "sessionID"), + ) as typeof MessageTable.$inferInsert.data, + }) + .run() + yield* tx + .insert(PartTable) + .values( + continuation.message.parts.map((part) => ({ + id: part.id, + message_id: continuation.message.info.id, + session_id: continuation.message.info.sessionID, + provenance: { + source: "compaction_continue" as const, + owner_session_id: input.sessionID, + owner_prompt_epoch: epoch.epoch, + owner_run_id: input.runID, + durable: true as const, + }, + time_created: continuation.message.info.time.created, + time_updated: continuation.message.info.time.created, + data: Object.fromEntries( + Object.entries(part).filter( + ([key]) => key !== "id" && key !== "messageID" && key !== "sessionID", + ), + ) as typeof PartTable.$inferInsert.data, + })), + ) + .run() + yield* tx + .insert(CompactionArtifactTable) + .values({ + artifact_id: Hash.sha256( + `compaction-artifact:v1:${input.runID}:${continuation.kind}:${continuation.message.info.id}:message`, + ), + run_id: input.runID, + session_id: continuation.message.info.sessionID, + message_id: continuation.message.info.id, + part_id: null, + kind: continuation.kind, + state: "committed", + created_at: committedAt, + committed_at: committedAt, + published_at: null, + }) + .run() + } + yield* tx + .update(CompactionArtifactTable) + .set({ state: "committed", committed_at: Date.now() }) + .where( + and(eq(CompactionArtifactTable.run_id, input.runID), eq(CompactionArtifactTable.state, "pending")), + ) + .run() return true }), { behavior: "immediate" }, @@ -514,16 +995,7 @@ export const layer = Layer.effect( keep = { start: turn.start, id: turn.id } continue } - const remaining = budget - total - const split = yield* splitTurn({ - messages: input.messages, - turn, - model: input.model, - budget: remaining, - estimate, - }) - if (split) keep = split - else if (!keep) log.info("tail fallback", { budget, size, total }) + if (!keep) log.info("tail fallback", { budget, size, total }) break } @@ -582,7 +1054,7 @@ export const layer = Layer.effect( } }) - const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { + const processCompactionAttempt = Effect.fn("SessionCompaction.processAttempt")(function* (input: { parentID: MessageID messages: SessionV1.WithParts[] sessionID: SessionID @@ -593,10 +1065,22 @@ export const layer = Layer.effect( if (!parent || parent.info.role !== "user") { throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } - const userMessage = parent.info const existingCompactionPart = parent.parts.find( (part): part is SessionV1.CompactionPart => part.type === "compaction", ) + const projection = yield* ( + existingCompactionPart + ? MessageV2.promptHistoryBeforeCompactionEffect({ + sessionID: input.sessionID, + markerMessageID: input.parentID, + }) + : MessageV2.promptHistoryProjectionEffect(input.sessionID) + ).pipe(Effect.provideService(Database.Service, { db }), Effect.orDie) + const activeEpoch = yield* promptEpoch.getActive(input.sessionID) + if (!activeEpoch || activeEpoch.authority_state !== "ready" || activeEpoch.epoch !== projection.epoch) { + return yield* Effect.die(new Error(`compaction history authority is unavailable for ${input.sessionID}`)) + } + const userMessage = parent.info const compactionPart = existingCompactionPart ?? ({ @@ -607,43 +1091,60 @@ export const layer = Layer.effect( auto: input.auto, overflow: input.overflow, } satisfies SessionV1.CompactionPart) - if (!existingCompactionPart) yield* session.updatePart(compactionPart) - yield* recover(input.sessionID) - const activeEpoch = yield* promptEpoch.bootstrap(input.sessionID) + const authorityInput = existingCompactionPart + ? input.messages.flatMap((message) => { + if (message.info.id !== input.parentID) return [message] + const parts = message.parts.filter((part) => part.type !== "compaction") + return parts.length === 0 ? [] : [{ info: message.info, parts }] + }) + : input.messages + if (HistoryAuthority.hash(authorityInput) !== projection.effectiveHistoryHash) { + return yield* Effect.die(new Error(`compaction input does not match active history for ${input.sessionID}`)) + } const run = yield* ensureRun({ sessionID: input.sessionID, markerMessageID: input.parentID, markerPartID: compactionPart.id, fromEpoch: activeEpoch.epoch, trigger: input.overflow ? "provider_overflow" : input.auto ? "turn_start" : "manual", + sourceWindowID: projection.window.windowID, + sourceEffectiveHistoryHash: projection.effectiveHistoryHash, + sourceMessageCount: projection.messages.length, + sourceProjectionVersion: projection.projectionVersion, }) - if (!run || run.state === "indeterminate") return "stop" - - let messages = input.messages - let replay: - | { - info: SessionV1.User - parts: SessionV1.Part[] - } - | undefined - if (input.overflow) { - const idx = input.messages.findIndex((m) => m.info.id === input.parentID) - for (let i = idx - 1; i >= 0; i--) { - const msg = input.messages[i] - if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { - replay = { info: msg.info, parts: msg.parts } - messages = input.messages.slice(0, i) - break - } - } - const hasContent = - replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) - if (!hasContent) { - replay = undefined - messages = input.messages - } + if (!run) return "stop" + if (existingCompactionPart) { + yield* registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: input.parentID, + partID: compactionPart.id, + kind: "marker", + }) + } + if (!existingCompactionPart) { + yield* events.publish( + SessionV1.Event.PartUpdated, + { sessionID: compactionPart.sessionID, part: compactionPart, time: Date.now() }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: input.parentID, + partID: compactionPart.id, + kind: "marker", + }), + }, + ) } + // Compaction is a history projection boundary, not a second user submission. + // Keep the original history for summarization and use the synthetic continuation below when + // the provider overflow was caused by media. This avoids durable `compaction_replay` user + // messages, which are indistinguishable from a real repeated prompt in the UI and history. + const messages = input.messages + const agent = yield* agents.get("compaction") const model = agent.model ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie) @@ -715,7 +1216,19 @@ export const layer = Layer.effect( created: Date.now(), }, } - yield* session.updateMessage(msg) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: msg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: msg.id, + kind: "summary_attempt", + }), + }, + ) // BUG-006 §5.1: establish the explicit summary request contract. // toolChoice:"none" tells the adapter the model must produce text only. @@ -778,15 +1291,25 @@ export const layer = Layer.effect( return "stop" } const retryMsg: SessionV1.Assistant = { ...msg, id: MessageID.ascending(), error: undefined, finish: undefined } - yield* session.updateMessage(retryMsg) + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: retryMsg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: retryMsg.id, + kind: "summary_attempt", + }), + }, + ) currentProcessor = yield* processors.create({ assistantMessage: retryMsg, sessionID: input.sessionID, model }) } if (result === "compact") { currentProcessor.message.error = new SessionV1.ContextOverflowError({ - message: replay - ? "Conversation history too large to compact - exceeds model context limit" - : "Session too large to compact - context exceeds model limit even after stripping media", + message: "Session too large to compact - context exceeds model limit even after stripping media", }).toObject() currentProcessor.message.finish = "error" yield* session.updateMessage(currentProcessor.message) @@ -794,158 +1317,200 @@ export const layer = Layer.effect( return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - - if (result === "continue" && input.auto) { - if (replay) { - const original = replay.info - const replayMsg = yield* session.updateMessage({ - id: MessageID.ascending(), + const continuation = yield* Effect.gen(function* () { + if (result !== "continue" || !input.auto) return + const info = yield* provider.getProvider(userMessage.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, + agent: userMessage.agent, + model: yield* provider + .getModel(userMessage.model.providerID, userMessage.model.modelID) + .pipe(Effect.orDie), + provider: { + source: info.source, + info, + options: info.options, + }, + message: userMessage, + overflow: input.overflow === true, + }, + { enabled: true }, + )).enabled + ) { + const continueMsg: SessionV1.User = { + id: MessageID.make( + `${currentProcessor.message.id}_continue_${Hash.sha256(`compaction-continue:v2:${run.run_id}`).slice(0, 12)}`, + ), role: "user", sessionID: input.sessionID, time: { created: Date.now() }, - agent: original.agent, - model: original.model, - format: original.format, - tools: original.tools, - system: original.system, - }) - for (const part of replay.parts) { - if (part.type === "compaction") continue - const replayPart = - part.type === "file" && MessageV2.isMedia(part.mime) - ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } - : part - yield* session.updatePart({ - ...replayPart, - id: PartID.ascending(), - messageID: replayMsg.id, - sessionID: input.sessionID, - }) + agent: userMessage.agent, + model: userMessage.model, + metadata: SessionProcessor.withPlanProtocolActivity( + { + deepagent: { + contextProvenance: { + source: "compaction_continue", + ownerSessionID: input.sessionID, + ownerPromptEpoch: activeEpoch.epoch + 1, + ownerRunID: run.run_id, + durable: true, + }, + }, + }, + SessionProcessor.planProtocolActivityID(userMessage.metadata) ?? userMessage.id, + ), } - } - - if (!replay) { - const info = yield* provider.getProvider(userMessage.model.providerID) - if ( - (yield* plugin.trigger( - "experimental.compaction.autocontinue", + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + const continueMessage: SessionV1.WithParts = { + info: continueMsg, + parts: [ { + id: PartID.make(`prt_${Hash.sha256(`compaction-continue-part:v1:${run.run_id}`).slice(0, 26)}`), + messageID: continueMsg.id, sessionID: input.sessionID, - agent: userMessage.agent, - model: yield* provider - .getModel(userMessage.model.providerID, userMessage.model.modelID) - .pipe(Effect.orDie), - provider: { - source: info.source, - info, - options: info.options, + type: "text", + // Internal marker for auto-compaction followups so provider plugins + // can distinguish them from manual post-compaction user prompts. + // This is not a stable plugin contract and may change or disappear. + metadata: { compaction_continue: true }, + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), }, - message: userMessage, - overflow: input.overflow === true, - }, - { enabled: true }, - )).enabled - ) { - const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - yield* session.updatePart({ - id: PartID.ascending(), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - // Internal marker for auto-compaction followups so provider plugins - // can distinguish them from manual post-compaction user prompts. - // This is not a stable plugin contract and may change or disappear. - metadata: { compaction_continue: true }, - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), }, - }) + ], } + return { message: continueMessage, kind: "continue" as const } } - } + }) if (currentProcessor.message.error) { yield* failRun(run.run_id, "summary_provider_error") return "stop" } if (result === "continue") { - const summary = summaryText( - (yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( - (item) => item.info.id === currentProcessor.message.id, - ) ?? { - info: msg, - parts: [], - }, - ) - if (flags.experimentalEventSystem) { - if (summary) - yield* events.publish(SessionEvent.Compaction.Ended, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.make(input.parentID), - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - text: summary ?? "", - recent, - }) + const persisted = yield* session.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const checkpointIndex = persisted.findIndex((item) => item.info.id === currentProcessor.message.id) + const checkpoint = persisted[checkpointIndex] ?? { + info: msg, + parts: [], + } + const summary = summaryText(checkpoint) + if (!summary) { + yield* failRun(run.run_id, "summary_text_missing") + return "stop" } - if (summary) { + const contextModel = yield* provider + .getModel(userMessage.model.providerID, userMessage.model.modelID) + .pipe(Effect.orDie) + const baselineExit = yield* Effect.exit(collectSessionWorldStateBaseline({ workspacePath: ctx.directory })) + if (Exit.isFailure(baselineExit)) { + yield* failRun(run.run_id, "world_state_baseline_failed") + return "stop" + } + const worldStateBaseline = baselineExit.value + const replacementParent = { + info: parent.info, + parts: [ + ...parent.parts.filter((part) => part.type !== "compaction"), + { ...compactionPart, tail_start_id: selected.tail_start_id }, + ], + } + const projected = yield* MessageV2.toModelMessagesEffect( + MessageV2.appendPromptWorldState({ + messages: [ + replacementParent, + checkpoint, + ...(tailIndex < 0 ? [] : history.slice(tailIndex)), + ...(checkpointIndex < 0 ? [] : persisted.slice(checkpointIndex + 1)), + ...(continuation ? [continuation.message] : []), + ], + sessionID: input.sessionID, + epoch: activeEpoch.epoch + 1, + baselineHash: worldStateBaseline.hash, + rendered: worldStateBaseline.rendered, + agent: userMessage.agent, + model: userMessage.model, + }), + contextModel, + ) + const estimated = Token.estimate(JSON.stringify(projected)) + const estimatedSummary = Token.estimate(summary) + const reportedSummary = currentProcessor.message.tokens.output + const contextTokens = Math.max( + 0, + estimated - estimatedSummary + (reportedSummary > 0 ? reportedSummary : estimatedSummary), + ) + const replacement = [ + { + info: replacementParent.info, + parts: [ + ...replacementParent.parts.filter((part) => part.type !== "compaction"), + { + ...compactionPart, + tail_start_id: selected.tail_start_id, + context_tokens: contextTokens, + }, + ], + }, + checkpoint, + ...(tailIndex < 0 ? [] : history.slice(tailIndex)), + ] + const effectiveHistoryHash = HistoryAuthority.hash(replacement) const committed = yield* commitRun({ runID: run.run_id, sessionID: input.sessionID, fromEpoch: run.from_prompt_epoch, + markerMessageID: input.parentID, + markerPartID: compactionPart.id, checkpointUserID: input.parentID, checkpointAssistantID: currentProcessor.message.id, - checkpointHash: Hash.sha256(`${run.run_id}:${msg.id}:${summary.slice(0, 256)}`), + checkpointHash: effectiveHistoryHash, + baseMessageCount: replacement.length, + effectiveHistoryHash, + replacementMessageIDs: replacement.map((message) => message.info.id), retainedTailStartID: selected.tail_start_id as MessageID | undefined, - sourceEndMessageID: selected.head.at(-1)?.info.id, + sourceEndMessageID: currentProcessor.message.id, + contextTokens, + summary, + recent, + reason: input.auto ? "auto" : "manual", + worldStateBaseline, + continuation, }) if (!committed) { yield* failRun(run.run_id, "compaction_commit_conflict") return "stop" } + yield* publishCommittedRun(run.run_id) } - - // V3.8 App-A Stage 1 (coexist, gated, default-safe): mirror the compaction summary into the - // structured Session Ledger. This does NOT change compaction behavior — it maintains the - // ledger as a structured-summary candidate for the Stage 2 Curator. updateLedgerFromSummary - // recovers the CAUSE internally and can never throw into this loop. - if (flags.experimentalContextLedger && summary) { - yield* updateLedgerFromSummary({ sessionID: input.sessionID, summary }) - // V3.8 App-A C3 (Stage 3): project the freshly-updated ledger into the project-level bridge - // so a future session in this workspace opens with the cross-session handoff. Same gate as - // the ledger mirror; carryOverToBridge recovers the CAUSE internally (never throws into this - // loop). ctx.directory is this session's workspace dir (the project-store key). - if (ctx.directory) { - yield* carryOverToBridge({ sessionID: input.sessionID, workspacePath: ctx.directory }) - } - } - yield* events.publish(Event.Compacted, { sessionID: input.sessionID }) } return result }) + const processCompaction = Effect.fn("SessionCompaction.process")(function* ( + input: Parameters[0], + ) { + if (activeCompactions.has(input.sessionID)) return "stop" as const + yield* recover(input.sessionID) + if (activeCompactions.has(input.sessionID)) return "stop" as const + activeCompactions.add(input.sessionID) + return yield* processCompactionAttempt(input).pipe( + Effect.ensuring(Effect.sync(() => activeCompactions.delete(input.sessionID))), + ) + }) + const create = Effect.fn("SessionCompaction.create")(function* (input: { sessionID: SessionID agent: string @@ -953,11 +1518,17 @@ export const layer = Layer.effect( auto: boolean overflow?: boolean trigger?: "turn_start" | "provider_overflow" | "manual" + activityID?: string }) { yield* recover(input.sessionID) - // BUG-005: ensure Epoch 0 exists before the first compaction so PromptEpoch is always - // the history authority even for sessions that were created before this migration. - const activeEpoch = yield* promptEpoch.bootstrap(input.sessionID) + const projection = yield* MessageV2.promptHistoryProjectionEffect(input.sessionID).pipe( + Effect.provideService(Database.Service, { db }), + Effect.orDie, + ) + const activeEpoch = yield* promptEpoch.getActive(input.sessionID) + if (!activeEpoch || activeEpoch.authority_state !== "ready" || activeEpoch.epoch !== projection.epoch) { + return yield* Effect.die(new Error(`compaction history authority is unavailable for ${input.sessionID}`)) + } const markerMessageID = MessageID.ascending() const markerPartID = PartID.ascending() @@ -967,19 +1538,37 @@ export const layer = Layer.effect( markerPartID, fromEpoch: activeEpoch.epoch, trigger: input.trigger ?? (input.overflow ? "provider_overflow" : input.auto ? "turn_start" : "manual"), + sourceWindowID: projection.window.windowID, + sourceEffectiveHistoryHash: projection.effectiveHistoryHash, + sourceMessageCount: projection.messages.length, + sourceProjectionVersion: projection.projectionVersion, }) - if (!run || run.state === "indeterminate") return + if (!run) return const marker = yield* Effect.exit( Effect.gen(function* () { - const msg = yield* session.updateMessage({ + const msg = { id: markerMessageID, role: "user", model: input.model, sessionID: input.sessionID, agent: input.agent, time: { created: Date.now() }, - }) + metadata: SessionProcessor.withPlanProtocolActivity(undefined, input.activityID ?? markerMessageID), + } satisfies SessionV1.User + yield* events.publish( + SessionV1.Event.MessageUpdated, + { sessionID: input.sessionID, info: msg }, + { + commit: () => + registerArtifact({ + runID: run.run_id, + sessionID: input.sessionID, + messageID: msg.id, + kind: "marker", + }), + }, + ) yield* session.updatePart({ id: markerPartID, messageID: msg.id, @@ -1006,11 +1595,59 @@ export const layer = Layer.effect( } }) + const hasPending = Effect.fn("SessionCompaction.hasPending")(function* (sessionID: SessionID) { + const row = yield* db + .select({ run_id: CompactionRunTable.run_id }) + .from(CompactionRunTable) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + inArray(CompactionRunTable.state, ["requested", "summarizing"] as const), + ), + ) + .get() + .pipe(Effect.orDie) + return row !== undefined + }) + + const recoverableContinuations = Effect.fn("SessionCompaction.recoverableContinuations")(function* ( + projectID: Project.ID, + ) { + const rows = yield* db + .select({ + runID: CompactionRunTable.run_id, + sessionID: CompactionRunTable.session_id, + messageID: CompactionArtifactTable.message_id, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .innerJoin(SessionTable, eq(SessionTable.id, CompactionRunTable.session_id)) + .where( + and( + eq(SessionTable.project_id, projectID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ + runID: row.runID, + sessionID: SessionID.make(row.sessionID), + messageID: MessageID.make(row.messageID), + })) + }) + return Service.of({ isOverflow, prune, process: processCompaction, create, + recover, + recoverableContinuations, + hasPending, }) }), ) diff --git a/packages/deepagent-code/src/session/llm/request.ts b/packages/deepagent-code/src/session/llm/request.ts index 79da1ac9c..eb345e6d0 100644 --- a/packages/deepagent-code/src/session/llm/request.ts +++ b/packages/deepagent-code/src/session/llm/request.ts @@ -94,7 +94,8 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre // provider-scoped. It applies to every upstream provider; `general` keeps the inherited // (deepagent-code) baseline prompt untouched. const agentMode = deepAgentAgentModeOverride(input.user.metadata) ?? AgentGateway.snapshot().agentMode - const isDeepAgentActive = AgentGateway.snapshot().mode === "enabled" && agentMode !== "general" + const isDeepAgentEnabled = AgentGateway.isDeepAgentRuntimeEnabled() + const isDeepAgentActive = isDeepAgentEnabled && agentMode !== "general" let system: string[] // The DeepAgent base system prompt stays byte-stable across a session. Per-turn runtime state // (round, stage, previous results, token budget, fan-out verdict) is rendered separately and sent @@ -102,6 +103,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre // entire history on Anthropic-compatible APIs and invalidate that provider-cache prefix. let volatileRoundContext = "" let volatileContextKind: "none" | "round" | "continuation" = "none" + let workflowPlanStatus: string | null = null let validationCommands: readonly string[] = [] if (isDeepAgentActive) { @@ -115,20 +117,24 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre promptContext.context.previousResults !== null // Fold round context and plan status into one runtime update. The stable system prompt identifies // this tagged tail as trusted control and requires the model to apply it silently. `renderPlanStatus` - // returns null in lightweight mode / no plan. A first-round, non-orchestrated task gets no update. + // returns null in lightweight mode / no plan. An existing Plan always gets a tail, including a + // fresh non-orchestrated activity, because its next write parameters must never depend on history. const isToolContinuation = input.messages.at(-1)?.role === "tool" - volatileContextKind = isToolContinuation ? "continuation" : runtimeSystemRequired ? "round" : "none" - const roundCtx = - volatileContextKind === "continuation" - ? AgentGateway.volatileContinuationContext() - : volatileContextKind === "round" - ? AgentGateway.volatileRoundContext(promptContext.context) - : "" + const baseContextKind = isToolContinuation ? "continuation" : runtimeSystemRequired ? "round" : "none" const planStatus = - volatileContextKind === "none" + input.agent.name === "compaction" ? null : SessionReminders.renderPlanStatus(input.sessionID, isToolContinuation ? "continuation" : "full") - volatileRoundContext = [roundCtx, planStatus].filter((x) => x && x.length > 0).join("\n\n") + workflowPlanStatus = planStatus + volatileRoundContext = + baseContextKind === "continuation" + ? AgentGateway.volatileContinuationContext(planStatus ?? undefined) + : baseContextKind === "round" + ? AgentGateway.volatileRoundContext(promptContext.context, planStatus ?? undefined) + : planStatus + ? AgentGateway.volatilePlanContext(planStatus) + : "" + volatileContextKind = baseContextKind === "continuation" ? "continuation" : volatileRoundContext ? "round" : "none" logPrompt(input.sessionID, promptContext.context.round, system[0]).catch(() => {}) } else { const baseAgentSystem = input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model) @@ -157,9 +163,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre .filter((x) => x) .join("\n"), ] - if (input.agent.name === "goal-worker") { - volatileRoundContext = - SessionReminders.renderPlanStatus(input.sessionID, "full", { includeLightweight: true }) ?? "" + if (isDeepAgentEnabled && input.agent.name !== "compaction") { + const planStatus = SessionReminders.renderPlanStatus(input.sessionID, "full", { includeLightweight: true }) + workflowPlanStatus = planStatus + volatileRoundContext = planStatus ? AgentGateway.volatilePlanContext(planStatus) : "" volatileContextKind = volatileRoundContext ? "round" : "none" } } @@ -235,6 +242,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ] .filter(Boolean) .join("\n\n") + // GitLab Workflow models receive `prepared.system` through their dedicated workflow protocol and + // intentionally do not receive synthetic user messages. Give them the exact Plan contract through + // that channel without moving unrelated volatile/reference context into the workflow system prompt. + if (workflowPlanStatus && input.isWorkflow) system.push(AgentGateway.volatilePlanContext(workflowPlanStatus)) const messages = runtimeTail && !input.isWorkflow ? [...baseMessages, { role: "user", content: runtimeTail } satisfies ModelMessage] diff --git a/packages/deepagent-code/src/session/processor.ts b/packages/deepagent-code/src/session/processor.ts index 261b42633..9de099209 100644 --- a/packages/deepagent-code/src/session/processor.ts +++ b/packages/deepagent-code/src/session/processor.ts @@ -230,6 +230,121 @@ export class ToolSequenceTracker { export type PlanProtocolOutcome = "success" | "progress" | "no_progress" | "invalid" | "conflict" | "schema" +type PlanProtocolHistoryMessage = { + readonly info: { + readonly id: string + readonly role: string + readonly parentID?: string + readonly metadata?: unknown + readonly time?: unknown + } + readonly parts: readonly { + readonly id: string + readonly type: string + readonly tool?: string + readonly callID?: string + readonly state?: { + readonly status: string + readonly metadata?: unknown + readonly time?: unknown + } + }[] +} + +export const planProtocolActivityID = (metadata: unknown): string | undefined => { + if (!isRecord(metadata) || !isRecord(metadata.deepagent)) return undefined + const activityID = metadata.deepagent.planProtocolActivityID + return typeof activityID === "string" && activityID.trim() !== "" ? activityID : undefined +} + +export const withPlanProtocolActivity = (metadata: unknown, activityID: string) => ({ + ...(isRecord(metadata) ? metadata : {}), + deepagent: { + ...(isRecord(metadata) && isRecord(metadata.deepagent) ? metadata.deepagent : {}), + planProtocolActivityID: activityID, + }, +}) + +// Rebuild the activity-scoped counter from durable tool parts. Every root prompt gets a fresh +// activity ID; steers and compaction continuations retain it. This keeps recovery independent of +// filtered provider history and prevents a process restart from silently restoring the full budget. +export const restorePlanProtocolFailures = (messages: readonly PlanProtocolHistoryMessage[]): number => { + const numericTime = (value: unknown, key: string) => { + if (!isRecord(value) || typeof value[key] !== "number") return 0 + return value[key] as number + } + const declaredActivityIDs = new Set( + messages + .filter((message) => message.info.role === "user") + .map((message) => planProtocolActivityID(message.info.metadata)) + .filter((activityID): activityID is string => activityID !== undefined), + ) + const users = messages + .filter((message) => message.info.role === "user") + .map((message) => ({ + messageID: message.info.id, + activityID: + planProtocolActivityID(message.info.metadata) ?? + (declaredActivityIDs.has(message.info.id) ? message.info.id : undefined), + created: numericTime(message.info.time, "created"), + })) + const latest = users + .toSorted((left, right) => left.created - right.created || left.messageID.localeCompare(right.messageID)) + .at(-1) + if (latest?.activityID === undefined) return 0 + const activities = new Map( + users + .filter((user): user is typeof user & { activityID: string } => user.activityID !== undefined) + .map((user) => [user.messageID, user.activityID] as const), + ) + const attempts = messages + .filter( + (message) => + message.info.role === "assistant" && + message.info.parentID !== undefined && + activities.get(message.info.parentID) === latest.activityID, + ) + .flatMap((message) => + message.parts + .filter( + (part) => + part.type === "tool" && + part.tool === "plan" && + (part.state?.status === "completed" || part.state?.status === "error"), + ) + .map((part) => ({ + messageID: message.info.id, + messageCreated: numericTime(message.info.time, "created"), + settled: numericTime(part.state?.time, "end") || numericTime(message.info.time, "created"), + part, + })), + ) + .toSorted( + (left, right) => + left.settled - right.settled || + left.messageCreated - right.messageCreated || + left.messageID.localeCompare(right.messageID) || + left.part.id.localeCompare(right.part.id), + ) + const uniqueAttempts = [ + ...new Map( + attempts.map((attempt) => [attempt.messageID + "\x00" + (attempt.part.callID ?? attempt.part.id), attempt] as const), + ).values(), + ] + return uniqueAttempts + .reduce((consecutive, item) => { + const metadata = item.part.state && isRecord(item.part.state.metadata) ? item.part.state.metadata : undefined + const protocol = metadata?.plan_protocol + if (protocol === "success" || protocol === "progress") return 0 + if (!(protocol === "invalid" || protocol === "conflict" || protocol === "schema" || protocol === "no_progress")) + return consecutive + const ordinal = metadata?.plan_attempt_ordinal + return typeof ordinal === "number" && Number.isSafeInteger(ordinal) && ordinal > 0 + ? Math.max(consecutive + 1, ordinal) + : consecutive + 1 + }, 0) +} + /** * Activity-scoped Plan Protocol budget. A malformed or stale model plan is * recoverable once; the second consecutive violation terminates the activity @@ -239,7 +354,11 @@ export type PlanProtocolOutcome = "success" | "progress" | "no_progress" | "inva export class PlanProtocolTracker { private readonly pending = new Set() private readonly settled = new Set() - private consecutiveViolations = 0 + private consecutiveViolations: number + + constructor(consecutiveViolations = 0) { + this.consecutiveViolations = Math.max(0, Math.floor(consecutiveViolations)) + } start(callID: string, toolName: string): void { if (toolName === "plan") this.pending.add(callID) @@ -529,6 +648,33 @@ export const layer = Layer.effect( ) } + const persistMissingPlanToolCall = Effect.fn("SessionProcessor.persistMissingPlanToolCall")(function* ( + toolCallID: string, + protocol: { readonly consecutive: number } | undefined, + error: string, + ) { + if (protocol === undefined) return + const now = Date.now() + yield* session.updatePart({ + id: PartID.ascending(), + messageID: ctx.assistantMessage.id, + sessionID: ctx.sessionID, + type: "tool", + tool: "plan", + callID: toolCallID, + state: { + status: "error", + input: {}, + error, + metadata: { + plan_protocol: "schema", + plan_attempt_ordinal: protocol.consecutive, + }, + time: { start: now, end: now }, + }, + } satisfies SessionV1.ToolPart) + }) + const recordProcessorInput = ( toolCallID: string, toolName: string, @@ -1192,12 +1338,21 @@ export const layer = Layer.effect( // tool-call part exists. Both belong to the activity-level plan protocol budget; // otherwise a malformed plan response silently escapes the terminal rule. yield* recordProcessorInput(value.id, value.name, "tool-result", "schema_invalid") - yield* settlePlanProtocol( - value.id, - value.name, - value.name === "plan" ? "schema" : "invalid", - "missing_tool_call", - ) + const protocol = + value.name === "plan" ? ctx.planTracker?.settle(planTrackerCallID(value.id), "schema") : undefined + if (protocol) { + yield* recordProcessorValidation(value.id, "schema_invalid") + yield* persistMissingPlanToolCall(value.id, protocol, "Plan result arrived without a durable tool call.") + if (protocol.terminal) + yield* Effect.fail( + new SessionV1.PlanProtocolViolationError({ + message: "Plan protocol violation budget exhausted after two consecutive model plan failures.", + sessionID: ctx.sessionID, + attemptOrdinal: protocol.consecutive, + code: "missing_tool_call", + }), + ) + } return } if (value.result.type === "error") { @@ -1338,6 +1493,12 @@ export const layer = Layer.effect( const protocol = value.name === "plan" ? ctx.planTracker?.settle(planTrackerCallID(value.id), protocolOutcome) : undefined if (protocol && !schemaInvalid) yield* recordProcessorValidation(value.id, "semantic_invalid") + if (protocol) + yield* persistMissingPlanToolCall( + value.id, + toolCall ? undefined : protocol, + schemaInvalid ? "Plan tool input failed schema validation before a durable tool call was written." : value.message, + ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (mirrorAssistant) { const assistantMessageID = toolCall diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 23e5d8a2d..a4fab3dde 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -5,6 +5,7 @@ import { SessionV1 } from "@deepagent-code/core/v1/session" import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" +import { HistoryAuthority } from "./history-authority" import { Log } from "@deepagent-code/core/util/log" import { Global } from "@deepagent-code/core/global" import { SessionRevert } from "./revert" @@ -95,6 +96,7 @@ import { archiveSessionOnCompletion } from "@/wiki/session-archive" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@deepagent-code/core/database/database" import { SessionToolRequestReceiptTable } from "./tool-request-receipt.sql" +import { CompactionArtifactTable, CompactionRunTable } from "./compaction-sql" import { SessionToolArgumentReceiptTable, type ToolArgumentReceiptLayer, @@ -119,14 +121,14 @@ import { } from "@deepagent-code/core/session/prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" -import { and, eq, max } from "drizzle-orm" -import { SessionTable, TaskRunTable } from "@deepagent-code/core/session/sql" +import { and, eq, inArray, isNull, max, ne, or } from "drizzle-orm" +import { SessionHistoryStateTable, SessionTable, TaskRunTable } from "@deepagent-code/core/session/sql" +import { SessionPromptEpochTable } from "./prompt-epoch.sql" import { referencePromptMetadata, referenceTextPart } from "./prompt/reference" import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@deepagent-code/llm" import { ConversationLogWriter } from "./conversation-log-writer" -import { collectVolatileFacts, refreshWorldState } from "./context-ledger" import { ToolSemanticFingerprint } from "@/tool/semantic-fingerprint" import { deliverTaskNotifications, recoverExpiredTaskRuns, classifyOnStartup, orderedShutdown } from "@/tool/task-run" // L10: durable control plane daemons @@ -139,6 +141,7 @@ import { PRQueue } from "@/agent/pr-queue" import { registerDisposer, registerInitializer } from "@/effect/instance-registry" import { EventRouteRef, InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" +import type { InstanceContext } from "@/project/instance-context" import { acquireDurableExecutorLease, releaseDurableExecutorLease, @@ -152,6 +155,268 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +const providerReceiptOwner = `${process.pid}:${randomUUID()}` + +export const recoverProviderReceiptsOnStartup = Effect.fn("SessionPrompt.recoverProviderReceiptsOnStartup")( + function* () { + const sessions = yield* Session.Service + const { db } = yield* Database.Service + const staleOwner = or( + isNull(SessionToolRequestReceiptTable.owner_token), + ne(SessionToolRequestReceiptTable.owner_token, providerReceiptOwner), + ) + const lostUnadmittedContinuations = yield* db + .select({ + sessionID: CompactionRunTable.session_id, + messageID: CompactionArtifactTable.message_id, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .all() + .pipe(Effect.orDie) + const lostUndispatchedReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(SessionToolRequestReceiptTable) + .where( + and(inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), staleOwner), + ) + .all() + .pipe(Effect.orDie) + const lostStartedReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(SessionToolRequestReceiptTable) + .where( + and(inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), staleOwner), + ) + .all() + .pipe(Effect.orDie) + const unresolvedContinuationReceipts = yield* db + .select({ + receiptID: SessionToolRequestReceiptTable.receipt_id, + sessionID: SessionToolRequestReceiptTable.session_id, + assistantMessageID: SessionToolRequestReceiptTable.assistant_message_id, + }) + .from(CompactionRunTable) + .innerJoin( + SessionToolRequestReceiptTable, + eq(SessionToolRequestReceiptTable.receipt_id, CompactionRunTable.continuation_receipt_id), + ) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "indeterminate"), + isNull(SessionToolRequestReceiptTable.response_fingerprint), + ), + ) + .all() + .pipe(Effect.orDie) + const unresolvedContinuationSessions = yield* db + .select({ sessionID: CompactionRunTable.session_id }) + .from(CompactionRunTable) + .where(and(eq(CompactionRunTable.state, "committed"), eq(CompactionRunTable.continuation_state, "indeterminate"))) + .all() + .pipe(Effect.orDie) + yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const undispatched = tx + .select({ receipt_id: SessionToolRequestReceiptTable.receipt_id }) + .from(SessionToolRequestReceiptTable) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), + staleOwner, + ), + ) + const started = tx + .select({ receipt_id: SessionToolRequestReceiptTable.receipt_id }) + .from(SessionToolRequestReceiptTable) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), + staleOwner, + ), + ) + const now = Date.now() + yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "pending", + continuation_receipt_id: null, + continuation_admitted_at: null, + continuation_dispatching_at: null, + continuation_terminal_at: null, + continuation_error_code: "provider_not_dispatched_before_process_restart", + continuation_wakeup_at: null, + }) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "admitted"), + inArray(CompactionRunTable.continuation_receipt_id, undispatched), + ), + ) + .run() + yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "indeterminate", + continuation_terminal_at: now, + continuation_error_code: "provider_started_outcome_unknown_after_process_restart", + }) + .where( + and( + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "dispatching"), + inArray(CompactionRunTable.continuation_receipt_id, started), + ), + ) + .run() + yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + provider_state: "indeterminate_after_crash", + terminal_at: now, + request_error_code: "provider_started_outcome_unknown_after_process_restart", + }) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["dispatching", "streaming"] as const), + staleOwner, + ), + ) + .run() + yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + provider_state: "failed", + terminal_at: now, + request_error_code: "provider_not_dispatched_before_process_restart", + }) + .where( + and( + inArray(SessionToolRequestReceiptTable.provider_state, ["preparing", "prepared"] as const), + staleOwner, + ), + ) + .run() + yield* Effect.forEach( + [ + ...new Set([ + ...lostStartedReceipts.map((receipt) => receipt.sessionID), + ...unresolvedContinuationSessions.map((continuation) => continuation.sessionID), + ]), + ], + (sessionID) => + Effect.gen(function* () { + const reason = "provider outcome is unknown after process restart" + yield* tx + .update(SessionPromptEpochTable) + .set({ authority_state: "recovery_required", recovery_reason: reason }) + .where( + and( + eq(SessionPromptEpochTable.session_id, sessionID), + eq(SessionPromptEpochTable.state, "active"), + ), + ) + .run() + yield* tx + .insert(SessionHistoryStateTable) + .values([ + { + session_id: SessionID.make(sessionID), + state: "recovery_required", + reason, + time_created: now, + time_updated: now, + }, + ]) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "recovery_required", reason, time_updated: now }, + }) + .run() + }), + { discard: true }, + ) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + yield* Effect.forEach( + [...lostUndispatchedReceipts, ...lostStartedReceipts, ...unresolvedContinuationReceipts], + (receipt) => + Effect.gen(function* () { + if (!receipt.assistantMessageID) return + const messages = yield* sessions.messages({ sessionID: SessionID.make(receipt.sessionID) }).pipe(Effect.orDie) + const assistant = messages.find( + (message) => message.info.id === receipt.assistantMessageID && message.info.role === "assistant", + ) + if (!assistant || assistant.info.role !== "assistant" || assistant.info.time.completed) return + yield* sessions.updateMessage({ + ...assistant.info, + finish: "error", + error: new NamedError.Unknown({ + message: + lostStartedReceipts.some((started) => started.receiptID === receipt.receiptID) || + unresolvedContinuationReceipts.some((unresolved) => unresolved.receiptID === receipt.receiptID) + ? `Provider request ${receipt.receiptID} may have been dispatched before restart; explicit recovery is required.` + : `Provider request ${receipt.receiptID} was not dispatched before restart; the durable continuation will be retried.`, + }).toObject(), + time: { ...assistant.info.time, completed: Date.now() }, + }) + }), + { discard: true }, + ) + yield* Effect.forEach( + lostUnadmittedContinuations, + (continuation) => + Effect.gen(function* () { + const messages = yield* sessions + .messages({ sessionID: SessionID.make(continuation.sessionID) }) + .pipe(Effect.orDie) + yield* Effect.forEach( + messages.filter( + (message): message is SessionV1.WithParts & { info: SessionV1.Assistant } => + message.info.role === "assistant" && + message.info.parentID === continuation.messageID && + !message.info.time.completed, + ), + (assistant) => + sessions.updateMessage({ + ...assistant.info, + finish: "error", + error: new NamedError.Unknown({ + message: + "The continuation process stopped before durable provider admission; the pending continuation will be retried.", + }).toObject(), + time: { ...assistant.info.time, completed: Date.now() }, + }), + { discard: true }, + ) + }), + { discard: true }, + ) + }, +) + // Coerce a structurally-valid Format value into a Format INSTANCE (see the call site in prompt()). const decodeFormatSync = Schema.decodeUnknownSync(SessionV1.Format) @@ -175,7 +440,14 @@ function buildStructuredOutputSystemPrompt(schema: Record): string return `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.${fieldHint}` } -function buildStructuredOutputRuntimeTail(format: SessionV1.OutputFormat, finalizerMode: boolean): string { +function buildStructuredOutputRuntimeTail( + format: SessionV1.OutputFormat, + finalizerMode: boolean, + finalizerAllowsText = false, +): string { + if (finalizerAllowsText) { + return "This is a bounded finalizer turn. Read the supplied research result and return exactly one JSON value. No research, Markdown, explanatory prose, or tool use is permitted." + } if (format.type !== "json_schema") return "" return [ buildStructuredOutputSystemPrompt(format.schema), @@ -209,6 +481,13 @@ function isStructuredFinalizer(metadata: unknown) { return isRecord(metadata.deepagent.structured_finalizer) } +function structuredFinalizerAllowsText(metadata: unknown) { + if (!isRecord(metadata)) return false + if (!isRecord(metadata.deepagent)) return false + if (!isRecord(metadata.deepagent.structured_finalizer)) return false + return metadata.deepagent.structured_finalizer.allow_text === true +} + function noninteractiveTaskActivity(metadata: unknown) { if (!isRecord(metadata)) return false if (!isRecord(metadata.deepagent)) return undefined @@ -282,7 +561,9 @@ const promptInputToPrompt = (parts: PromptInput["parts"]): Effect.Effect Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly promptAsync: (input: PromptInput) => Effect.Effect + readonly promptAsync: ( + input: PromptInput, + ) => Effect.Effect readonly prepareTaskInput: ( input: PromptInput, timeCreated: number, @@ -334,16 +615,18 @@ export interface Interface { export class Service extends Context.Service()("@deepagent-code/SessionPrompt") {} +export type PromptAdmissionReceipt = { + readonly messageID: MessageID + readonly delivery: SessionPromptIntent.Delivery +} + type PromptLifecycle = { readonly intent?: SessionPromptIntent.Receipt & { readonly state: "admitting" readonly ownerToken: string readonly messageID: MessageID } - readonly ready: (input: { - readonly messageID: MessageID - readonly delivery: SessionPromptIntent.Delivery - }) => Effect.Effect + readonly ready: (input: PromptAdmissionReceipt) => Effect.Effect } type ExecutePrompt = ( @@ -407,6 +690,7 @@ export const layer = Layer.effect( ) const database = yield* Database.Service const { db } = database + yield* recoverProviderReceiptsOnStartup() const activeFederatedContexts = new Map() const settleFederatedActivity = (sessionID: SessionID, state: "settled" | "failed" | "interrupted") => Effect.gen(function* () { @@ -794,6 +1078,10 @@ export const layer = Layer.effect( time: { created: Date.now() }, agent: lastUser.agent, model: lastUser.model, + metadata: SessionProcessor.withPlanProtocolActivity( + undefined, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ), } yield* sessions.updateMessage(summaryUserMsg) yield* sessions.updatePart({ @@ -1367,8 +1655,9 @@ export const layer = Layer.effect( : undefined const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) + const messageID = input.messageID ?? MessageID.ascending() const info: SessionV1.User = { - id: input.messageID ?? MessageID.ascending(), + id: messageID, role: "user", sessionID: input.sessionID, time: { created: options?.timeCreated ?? Date.now() }, @@ -1389,7 +1678,7 @@ export const layer = Layer.effect( // is idempotent for callers that already pass an instance. `withDecodingDefault` also fills // retryCount. `format` is validated on the way in (PromptInput), so this decode never fails. format: input.format === undefined ? undefined : decodeFormatSync(input.format), - metadata: input.metadata, + metadata: SessionProcessor.withPlanProtocolActivity(input.metadata, messageID), } if (persist) yield* Effect.addFinalizer(() => instruction.clear(info.id)) @@ -1876,6 +2165,8 @@ export const layer = Layer.effect( input: PromptInput, lifecycle?: PromptLifecycle, ) { + yield* sessions.recoverForks() + yield* sessions.assertRunnable(input.sessionID).pipe(Effect.orDie) const notification = taskNotification(input.metadata) if (notification && input.messageID) { const existing = yield* MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }).pipe( @@ -2184,10 +2475,25 @@ export const layer = Layer.effect( // revert either follows the completed append or supersedes the steer before it can write anything. const steerPartID = (messageID: MessageID, suffix?: string) => PartID.make("prt_" + messageID.slice("msg_".length) + (suffix ?? "")) - const drainSteers = Effect.fn("SessionPrompt.drainSteers")(function* (sessionID: SessionID) { + const drainSteers = Effect.fn("SessionPrompt.drainSteers")(function* (sessionID: SessionID, startActivity = false) { if (!flags.v4Steering) return [] as SessionMessage.ID[] const pending = yield* steerBuffer.pending(sessionID) if (pending.length === 0) return [] as SessionMessage.ID[] + const activityID = + (startActivity + ? undefined + : (yield* MessageV2.stream(sessionID).pipe(Effect.provideService(Database.Service, database), Effect.orDie)) + .filter((message) => message.info.role === "user") + .toSorted( + (left, right) => + left.info.time.created - right.info.time.created || left.info.id.localeCompare(right.info.id), + ) + .map((message) => + SessionProcessor.planProtocolActivityID( + message.info.role === "user" ? message.info.metadata : undefined, + ), + ) + .findLast((value) => value !== undefined)) ?? pending[0]!.id const current = yield* db .select({ agent: SessionTable.agent, model: SessionTable.model }) .from(SessionTable) @@ -2205,18 +2511,34 @@ export const layer = Layer.effect( const variant = "variant" in resolved ? resolved.variant : undefined const persisted: SessionMessage.ID[] = [] for (const admitted of pending) { + const materializedAt = yield* steerBuffer + .materializationTime(admitted) + .pipe(Effect.catchTag("SessionMutationEpoch.Stale", () => Effect.succeed(undefined))) + if (materializedAt === undefined) continue const agentName = admitted.prompt.agents?.[0]?.name ?? defaultAgent const info: SessionV1.User = { id: MessageID.make(admitted.id), role: "user", sessionID, - time: { created: admitted.timeCreated }, + time: { created: materializedAt }, agent: agentName, model: { providerID: resolved.providerID, modelID: resolved.modelID, ...(variant ? { variant } : {}), }, + metadata: SessionProcessor.withPlanProtocolActivity( + admitted.correlationID + ? { + deepagent: { + promptAdmission: { + clientMessageID: admitted.correlationID, + }, + }, + } + : undefined, + activityID, + ), } const parts: SessionV1.Part[] = [] if (admitted.prompt.text.length > 0) @@ -2305,8 +2627,9 @@ export const layer = Layer.effect( text: string, model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }, agentName: string, + activityID: string, ) => Effect.Effect = Effect.fn("SessionPrompt.injectTailReminder")( - function* (sessionID, text, model, agentName) { + function* (sessionID, text, model, agentName, activityID) { const msg = yield* sessions.updateMessage({ id: MessageID.ascending(), role: "user", @@ -2314,6 +2637,7 @@ export const layer = Layer.effect( agent: agentName, model, time: { created: Date.now() }, + metadata: SessionProcessor.withPlanProtocolActivity(undefined, activityID), }) yield* sessions.updatePart({ id: PartID.ascending(), @@ -2365,26 +2689,6 @@ export const layer = Layer.effect( "", ].join("\n") - // V4.0.1 P1 (§3.3) — post-hard-compaction World State re-injection. After a hard compaction the - // (now-narrowed) summary deliberately dropped file/env/diagnostics; this re-injects their LATEST - // values as a TAIL user block (reuses the SAME injectTailReminder primitive — never the static system - // prefix, so prompt cache is preserved) so the model sees current truth, not a stale summary value. - // Gated by worldStateReinjection (the same flag that narrowed the summary — no information hole). - // Bounded IO: git + env only, collected once per compaction. Default-safe: any defect ⇒ no-op. - const injectWorldStateTail: ( - sessionID: SessionID, - workspacePath: string | undefined, - model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }, - agentName: string, - ) => Effect.Effect = Effect.fn("SessionPrompt.injectWorldStateTail")( - function* (sessionID, workspacePath, model, agentName) { - if (!workspacePath) return - const facts = yield* collectVolatileFacts(workspacePath) - const rendered = yield* refreshWorldState({ workspacePath, facts }) - if (rendered.trim().length > 0) yield* injectTailReminder(sessionID, rendered, model, agentName) - }, - ) - const runLoop: (sessionID: SessionID, drainFirst?: boolean) => Effect.Effect = Effect.fn( "SessionPrompt.run", )( @@ -2403,7 +2707,10 @@ export const layer = Layer.effect( // that occurs when the model repeatedly guesses wrong field names (e.g. "summary" instead // of "module" for ResearchResult) and the AI SDK silently rejects them before execute(). let structuredFailedAttempts = 0 + yield* sessions.recoverForks() + yield* sessions.assertRunnable(sessionID).pipe(Effect.orDie) const session = yield* sessions.get(sessionID).pipe(Effect.orDie) + yield* compaction.recover(sessionID) const sessionFederationEligibility = ContextFederationRollout.resolveProject( federationRollout, session.projectID, @@ -2427,12 +2734,23 @@ export const layer = Layer.effect( // F1: one tracker per durable user activity; shared by every provider step (processor // instance) created in this runLoop call so cross-message ABABAB/ABCABC/... patterns - // are detectable. Reset implicitly on the next runLoop invocation (new variable). + // are detectable. const toolSequenceTracker = new SessionProcessor.ToolSequenceTracker() - const planProtocolTracker = new SessionProcessor.PlanProtocolTracker() - const initialMessages = yield* MessageV2.promptHistoryEffect(sessionID).pipe( + const initialMessages = yield* MessageV2.promptControlHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, + ) + // Plan failures are protocol state, not advisory loop state. Rebuild them from unfiltered + // durable parts so a committed compaction or process restart cannot restore the attempt budget. + const planProtocolTracker = new SessionProcessor.PlanProtocolTracker( + drainFirst + ? 0 + : yield* MessageV2.stream(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.map(SessionProcessor.restorePlanProtocolFailures), + Effect.orDie, + ), ) const initialUser = MessageV2.latest(initialMessages).user const initialFinalizer = isStructuredFinalizer(initialUser?.metadata) @@ -2480,12 +2798,15 @@ export const layer = Layer.effect( // top-of-loop finish check (`lastUser.id < lastAssistant.id`) to keep looping — so a steer that // arrived after the model said "done" is naturally absorbed on this next pass. if (step > 0 || drainFirst) { - const absorbed = yield* drainSteers(sessionID) - pendingContextInputIds = [...pendingContextInputIds, ...absorbed] + if (!(yield* compaction.hasPending(sessionID))) { + const absorbed = yield* drainSteers(sessionID, drainFirst && step === 0) + pendingContextInputIds = [...pendingContextInputIds, ...absorbed] + } } - let msgs = yield* MessageV2.promptHistoryEffect(sessionID).pipe( + let msgs = yield* MessageV2.promptControlHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, ) // Archive everything settled so far (user turn + any completed assistant/tool parts from the @@ -2497,6 +2818,7 @@ export const layer = Layer.effect( if (!lastUser) throw new Error("No user message found in stream. This should never happen.") const finalizerMode = isStructuredFinalizer(lastUser.metadata) + const finalizerAllowsText = structuredFinalizerAllowsText(lastUser.metadata) const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, @@ -2542,6 +2864,7 @@ export const layer = Layer.effect( : OUTPUT_CONTINUE_TAIL_TEXT, lastUser.model, lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, ) yield* slog.info("output soft-landing: continuing after length cutoff", { continuation: done + 1, @@ -2602,18 +2925,12 @@ export const layer = Layer.effect( if (task?.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: lastUser.id, + parentID: MessageID.make(task.messageID), sessionID, auto: task.auto, overflow: task.overflow, }) if (result === "stop") break - // Inject volatile state only after the compaction summary is durable. Injecting it when the - // compaction marker is created makes this synthetic user message the next iteration's lastUser, - // so compaction.process pairs the summary with the wrong parent and the marker never becomes a - // completed compaction boundary. - if (task.auto && flags.worldStateReinjection) - yield* injectWorldStateTail(sessionID, ctx.directory, lastUser.model, lastUser.agent) continue } @@ -2634,7 +2951,13 @@ export const layer = Layer.effect( outputTokenMax: flags.outputTokenMax, }) ) { - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + }) continue } } else { @@ -2664,13 +2987,25 @@ export const layer = Layer.effect( const { action, nextState } = softLandingDecision({ status, state: slState, step }) if (action === "reminder") { yield* writeSoftLandingState(sessionID, nextState) - yield* injectTailReminder(sessionID, REMINDER_TAIL_TEXT, lastUser.model, lastUser.agent) + yield* injectTailReminder( + sessionID, + REMINDER_TAIL_TEXT, + lastUser.model, + lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ) yield* slog.info("soft-landing reminder injected", { used: status.used, softLine: status.softLine }) continue } if (action === "fallback") { yield* writeSoftLandingState(sessionID, nextState) - yield* injectTailReminder(sessionID, fallbackTailText(sessionID), lastUser.model, lastUser.agent) + yield* injectTailReminder( + sessionID, + fallbackTailText(sessionID), + lastUser.model, + lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + ) yield* slog.info("soft-landing fallback injected", { used: status.used, fallbackLine: status.fallbackLine, @@ -2684,7 +3019,13 @@ export const layer = Layer.effect( // history boundary marker — PromptEpoch is the sole history authority and is only // activated by compaction.process() on confirmed successful summary (CompactionCommitted). yield* writeSoftLandingState(sessionID, nextState) - yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true }) + yield* compaction.create({ + sessionID, + agent: lastUser.agent, + model: lastUser.model, + auto: true, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, + }) continue } if (action === "guard") { @@ -2708,6 +3049,39 @@ export const layer = Layer.effect( } const maxSteps = Math.min(agent.steps ?? Infinity, taskActivity?.maxSteps ?? Infinity) const isLastStep = step >= maxSteps + const promptAuthority = finalizerMode + ? undefined + : yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + const receiptAuthority = + promptAuthority ?? + (yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + )) + yield* sessions.assertRunnable(sessionID).pipe(Effect.orDie) + if (promptAuthority && HistoryAuthority.hash(msgs) !== promptAuthority.effectiveHistoryHash) { + return yield* Effect.die(new Error(`Prompt history changed during provider request assembly: ${sessionID}`)) + } + const worldState = finalizerMode + ? undefined + : yield* MessageV2.promptWorldStateProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + if ( + worldState && + promptAuthority && + (worldState.epoch !== promptAuthority.epoch || + worldState.windowID !== promptAuthority.window.windowID || + worldState.effectiveHistoryHash !== promptAuthority.effectiveHistoryHash) + ) { + return yield* Effect.die( + new Error(`World State authority changed during provider request assembly: ${sessionID}`), + ) + } if (!finalizerMode) { msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe( Effect.provideService(RuntimeFlags.Service, flags), @@ -2715,7 +3089,6 @@ export const layer = Layer.effect( Effect.provideService(Session.Service, sessions), ) } - const msg: SessionV1.Assistant = { id: MessageID.ascending(), parentID: lastUser.id, @@ -2733,7 +3106,7 @@ export const layer = Layer.effect( } yield* sessions.updateMessage(msg) - if (finalizerDecision?.capability === "unsupported") { + if (finalizerDecision?.capability === "unsupported" && !finalizerAllowsText) { msg.error = new NamedError.Unknown({ message: `[${finalizerDecision.reason}] Structured finalization requires tool-call capability.`, }).toObject() @@ -2757,6 +3130,18 @@ export const layer = Layer.effect( yield* sessions.updateMessage(msg) }) + const receiptTerminal: { + value?: { state: "settled" } | { state: "failed"; errorCode: string } + } = {} + const receiptFinalizer: { value?: () => Effect.Effect } = {} + const finalizeInterruptedTurn = Effect.uninterruptible( + Effect.gen(function* () { + yield* finalizeInterruptedAssistant + receiptTerminal.value ??= { state: "failed", errorCode: "AbortError" } + if (receiptFinalizer.value) yield* receiptFinalizer.value() + }), + ) + const handle = yield* processor .create({ assistantMessage: msg, @@ -2767,7 +3152,7 @@ export const layer = Layer.effect( loopPolicy: finalizerMode || taskActivity ? "error" : "ask", noProgressLimit: taskActivity?.maxNoProgress, }) - .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant)) + .pipe(Effect.onInterrupt(() => finalizeInterruptedTurn)) const outcome: "break" | "continue" = yield* Effect.gen(function* () { sessionFederationRollout = yield* activateFederation() @@ -2809,17 +3194,17 @@ export const layer = Layer.effect( ToolSemanticFingerprint.resolveResult(tools[toolName], result), ) - if (step === 1 && !finalizerMode) - yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - - if (!finalizerMode) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const providerHistory = finalizerMode + ? msgs + : (yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: structuredClone(msgs) })) + .messages // PR-1: Compute the terminal boundary for cross-model reasoning projection. // The most recent settled assistant message (has finish, no pending tool calls) // defines the boundary. Same-model reasoning remains append-only because removing // signed thinking after settlement rewrites the provider prefix and busts its cache. let terminalBoundaryID: MessageID | undefined - for (const msg of msgs) { + for (const msg of providerHistory) { if (msg.info.role !== "assistant") continue if (!msg.info.finish) continue const hasPendingToolCalls = msg.parts.some( @@ -2833,11 +3218,20 @@ export const layer = Layer.effect( } const format = lastUser.format ?? { type: "text" as const } - const modelMsgs = yield* MessageV2.toModelMessagesEffect( - finalizerMode ? msgs.filter((item) => item.info.id === lastUser.id) : msgs, - model, - { terminalBoundaryID }, - ) + const historyForProvider = finalizerMode + ? providerHistory.filter((item) => item.info.id === lastUser.id) + : worldState + ? MessageV2.appendPromptWorldState({ + messages: providerHistory, + sessionID, + epoch: worldState.epoch, + baselineHash: worldState.hash, + rendered: worldState.rendered, + agent: lastUser.agent, + model: lastUser.model, + }) + : providerHistory + const modelMsgs = yield* MessageV2.toModelMessagesEffect(historyForProvider, model, { terminalBoundaryID }) const system = yield* Effect.all([ sys.skills(agent), sys.environment(model), @@ -2879,8 +3273,9 @@ export const layer = Layer.effect( : "" // Schema/finalizer guidance changes per request and must not enter the provider-cached // system prefix. Keep it in the same ephemeral tail used for other volatile runtime - // context; the durable user prompt and the StructuredOutput tool remain the hard gates. - const structuredRuntimeTail = buildStructuredOutputRuntimeTail(format, finalizerMode) + // context; strict turns use StructuredOutput as the hard gate, while the bounded text + // fallback uses local JSON extraction plus the unchanged schema validator. + const structuredRuntimeTail = buildStructuredOutputRuntimeTail(format, finalizerMode, finalizerAllowsText) const baseStreamInput: LLM.StreamInput = { user: lastUser, agent, @@ -2891,7 +3286,10 @@ export const layer = Layer.effect( messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], tools: isLastStep ? {} : tools, model, - toolChoice: finalizerDecision?.toolChoice ?? (format.type === "json_schema" ? "required" : undefined), + durableAttempt: true, + toolChoice: finalizerAllowsText + ? "none" + : (finalizerDecision?.toolChoice ?? (format.type === "json_schema" ? "required" : undefined)), reasoning: finalizerDecision?.reasoning, ...(structuredRuntimeTail ? { runtimeTail: structuredRuntimeTail } : {}), ...(!projectedContext && activeContext @@ -2945,8 +3343,29 @@ export const layer = Layer.effect( ? preparedProviderAttempt.value : undefined const registryToolIds = yield* registry.ids() - const fallbackReceiptID = Hash.sha256(`${sessionID}:provider-request:${handle.message.id}:${randomUUID()}`) - const receiptAdmission = yield* db + if (promptAuthority) { + const dispatchProjection = yield* MessageV2.promptHistoryProjectionEffect(sessionID).pipe( + Effect.provideService(Database.Service, database), + Effect.orDie, + ) + const boundaryError = MessageV2.validateProviderPromptBoundary({ + authority: promptAuthority, + dispatch: dispatchProjection, + assistantMessageID: msg.id, + parentMessageID: lastUser.id, + }) + if (boundaryError) { + const error = new NamedError.Unknown({ + message: `Prompt history changed before provider dispatch: ${sessionID}: ${boundaryError}`, + }) + msg.error = error.toObject() + msg.finish = "error" + msg.time.completed = Date.now() + yield* sessions.updateMessage(msg) + return yield* Effect.die(error) + } + } + const receiptID = yield* db .transaction( (tx) => Effect.gen(function* () { @@ -2959,6 +3378,32 @@ export const layer = Layer.effect( const receiptID = Hash.sha256( `${sessionID}:provider-request:${requestOrdinal}:${handle.message.id}`, ) + const continuation = yield* tx + .select({ + runID: CompactionRunTable.run_id, + state: CompactionRunTable.continuation_state, + }) + .from(CompactionRunTable) + .innerJoin(CompactionArtifactTable, eq(CompactionArtifactTable.run_id, CompactionRunTable.run_id)) + .where( + and( + eq(CompactionRunTable.session_id, sessionID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + eq(CompactionArtifactTable.session_id, sessionID), + eq(CompactionArtifactTable.message_id, lastUser.id), + eq(CompactionArtifactTable.state, "committed"), + inArray(CompactionArtifactTable.kind, ["replay", "continue"] as const), + ), + ) + .get() + if (continuation && continuation.state !== "pending") + return yield* Effect.die( + new Error( + `compaction continuation is not pending: ${continuation.runID}: ${continuation.state ?? "missing"}`, + ), + ) + const admittedAt = Date.now() yield* tx.insert(SessionToolRequestReceiptTable).values({ receipt_id: receiptID, request_ordinal: requestOrdinal, @@ -2977,46 +3422,64 @@ export const layer = Layer.effect( tool_choice_mode: streamInput.toolChoice, adapter_tool_capability: "unknown", adapter_lowering_outcome: null, + prompt_epoch: receiptAuthority.epoch, + prompt_window_id: receiptAuthority.window.windowID, + effective_history_hash: receiptAuthority.effectiveHistoryHash, + world_state_baseline_hash: worldState?.hash, + request_input_hash: providerRequestHash(streamInput), + response_chain_reuse_decision: "not_supported", + response_chain_refusal_reason: "provider_path_not_stateful", + provider_state: "preparing", + owner_token: providerReceiptOwner, request_state: "prepared", - created_at: Date.now(), + created_at: admittedAt, }) - return { receiptID, admitted: true as const } + if (continuation) { + const admitted = yield* tx + .update(CompactionRunTable) + .set({ + continuation_state: "admitted", + continuation_receipt_id: receiptID, + continuation_admitted_at: admittedAt, + continuation_dispatching_at: null, + continuation_terminal_at: null, + continuation_error_code: null, + continuation_wakeup_at: admittedAt, + }) + .where( + and( + eq(CompactionRunTable.run_id, continuation.runID), + eq(CompactionRunTable.state, "committed"), + eq(CompactionRunTable.continuation_state, "pending"), + ), + ) + .returning({ runID: CompactionRunTable.run_id }) + .get() + if (!admitted) + return yield* Effect.die( + new Error(`compaction continuation admission CAS lost: ${continuation.runID}`), + ) + } + return receiptID }), { behavior: "immediate" }, ) - .pipe( + .pipe(Effect.orDie) + const bestEffortReceiptWrite = (operation: string, write: Effect.Effect) => + write.pipe( + Effect.asVoid, Effect.catchCause((cause) => Effect.sync(() => { - slog.warn("provider request receipt admission failed", { + slog.warn("provider argument receipt write failed", { + operation, + receiptID, cause: Cause.pretty(cause), - metric: "provider_request_receipt_degraded_total", + metric: "provider_argument_receipt_degraded_total", increment: 1, }) - return { receiptID: fallbackReceiptID, admitted: false as const } }), ), ) - const receiptID = receiptAdmission.receiptID - const receiptWriteState = { available: receiptAdmission.admitted } - const bestEffortReceiptWrite = (operation: string, write: Effect.Effect) => - Effect.suspend(() => { - if (!receiptWriteState.available) return Effect.void - return write.pipe( - Effect.asVoid, - Effect.catchCause((cause) => - Effect.sync(() => { - receiptWriteState.available = false - slog.warn("provider request receipt write failed", { - operation, - receiptID, - cause: Cause.pretty(cause), - metric: "provider_request_receipt_degraded_total", - increment: 1, - }) - }), - ), - ) - }) const writeArgumentReceipt = (input: { layer: ToolArgumentReceiptLayer ordinal: number @@ -3049,125 +3512,351 @@ export const layer = Layer.effect( }) .run(), ) - const result = yield* handle.process( - { - ...streamInput, - requestReceipt: { - prepared: (prepared) => { - const finalOfferedToolIds = Object.keys(prepared.finalOfferedTools) - const definitions = Object.entries(prepared.finalOfferedTools) - .toSorted(([a], [b]) => a.localeCompare(b)) - .map(([name, definition]) => ({ - name, - description: definition.description, - inputSchema: "inputSchema" in definition ? definition.inputSchema : undefined, - })) - return bestEffortReceiptWrite( - "prepared", - db + const transitionReceipt = (input: { + from: readonly (typeof SessionToolRequestReceiptTable.$inferSelect.provider_state)[] + to: typeof SessionToolRequestReceiptTable.$inferSelect.provider_state + values?: Partial + }) => + db + .transaction( + (tx) => + Effect.gen(function* () { + const updated = yield* tx .update(SessionToolRequestReceiptTable) - .set({ - permission_filtered_tool_ids: [...prepared.permissionFilteredToolIds], - final_offered_tool_ids: finalOfferedToolIds, - tool_definition_hash: Hash.sha256(stableJson(definitions)), - adapter_tool_capability: prepared.adapterToolCapability, - adapter_lowering_outcome: prepared.adapterLoweringOutcome, - estimated_input_tokens: prepared.budget.estimatedFullRequestTokens, - physical_input_budget: prepared.budget.physicalInputBudget, - reserved_output_tokens: prepared.budget.reservedOutputTokens, - safety_margin_tokens: prepared.budget.safetyMargin, - context_limit_provenance: prepared.budget.provenance, - request_state: "prepared", - }) + .set({ ...input.values, provider_state: input.to }) .where( and( eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + inArray(SessionToolRequestReceiptTable.provider_state, [...input.from]), ), ) - .run(), - ) - }, - dispatched: () => - bestEffortReceiptWrite( - "dispatched", - db - .update(SessionToolRequestReceiptTable) - .set({ request_state: "dispatched" }) - .where( - and( - eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + .returning({ receiptID: SessionToolRequestReceiptTable.receipt_id }) + .get() + if (!updated) { + const current = yield* tx + .select({ state: SessionToolRequestReceiptTable.provider_state }) + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + if (current?.state === input.to) return false + return yield* Effect.die( + new Error( + `provider receipt transition conflict: ${receiptID}: ${current?.state ?? "missing"} -> ${input.to}`, ), ) - .run(), - ), - rejected: ({ budget, reason }) => - bestEffortReceiptWrite( - "rejected", - db - .update(SessionToolRequestReceiptTable) + } + + const continuation = yield* tx + .select({ + runID: CompactionRunTable.run_id, + state: CompactionRunTable.continuation_state, + }) + .from(CompactionRunTable) + .where(eq(CompactionRunTable.continuation_receipt_id, receiptID)) + .get() + if (!continuation || input.to === "streaming") return true + const target = + input.to === "dispatching" + ? "dispatching" + : input.to === "settled" + ? "settled" + : input.to === "failed" + ? "failed" + : undefined + if (!target) return true + const expected: readonly ("admitted" | "dispatching")[] = + target === "dispatching" + ? ["admitted"] + : target === "settled" + ? ["dispatching"] + : ["admitted", "dispatching"] + if (continuation.state !== "admitted" && continuation.state !== "dispatching") + return yield* Effect.die( + new Error( + `compaction continuation transition conflict: ${continuation.runID}: ${continuation.state ?? "missing"} -> ${target}`, + ), + ) + const transitionedAt = Date.now() + const continuationUpdated = yield* tx + .update(CompactionRunTable) .set({ - estimated_input_tokens: budget.estimatedFullRequestTokens, - physical_input_budget: budget.physicalInputBudget, - reserved_output_tokens: budget.reservedOutputTokens, - safety_margin_tokens: budget.safetyMargin, - context_limit_provenance: budget.provenance, - request_state: "rejected", - request_error_code: reason, + continuation_state: target, + ...(target === "dispatching" + ? { + continuation_dispatching_at: + typeof input.values?.dispatching_at === "number" + ? input.values.dispatching_at + : transitionedAt, + } + : { + continuation_terminal_at: + typeof input.values?.terminal_at === "number" + ? input.values.terminal_at + : transitionedAt, + continuation_error_code: + target === "failed" ? (input.values?.request_error_code ?? "provider_error") : null, + }), }) .where( and( - eq(SessionToolRequestReceiptTable.receipt_id, receiptID), - eq(SessionToolRequestReceiptTable.request_state, "prepared"), + eq(CompactionRunTable.run_id, continuation.runID), + inArray(CompactionRunTable.continuation_state, expected), ), ) - .run(), - ), - aiSdkInput: (input) => writeArgumentReceipt({ layer: "ai_sdk_input", ...input }), - rawFrame: (input) => writeArgumentReceipt({ layer: "raw_frame", ...input }), - adapterAssembly: (input) => writeArgumentReceipt({ layer: "adapter_assembly", ...input }), - processorDecoded: (input) => writeArgumentReceipt({ layer: "processor_decoded", ...input }), - processorValidation: (input) => - bestEffortReceiptWrite( - "argument:processor_decoded:validation", - db - .update(SessionToolArgumentReceiptTable) - .set({ validation_outcome: input.validationOutcome }) + .returning({ runID: CompactionRunTable.run_id }) + .get() + if (!continuationUpdated) + return yield* Effect.die( + new Error(`compaction continuation transition CAS lost: ${continuation.runID}`), + ) + return true + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + const finalizeReceipt = () => + Effect.gen(function* () { + const terminal = receiptTerminal.value + if (!terminal) + return yield* Effect.die(new Error(`provider response terminal intent is missing: ${receiptID}`)) + const finalResponse = (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)).find( + (message) => message.info.id === handle.message.id, + ) + if (!finalResponse) + return yield* Effect.die(new Error(`provider response is missing: ${handle.message.id}`)) + const responseFingerprint = providerResponseFingerprint(finalResponse) + const finalized = yield* transitionReceipt({ + from: + terminal.state === "settled" + ? (["dispatching", "streaming"] as const) + : (["preparing", "prepared", "dispatching", "streaming"] as const), + to: terminal.state, + values: { + call_ids: finalResponse.parts.flatMap((part) => (part.type === "tool" ? [part.callID] : [])), + response_fingerprint: responseFingerprint, + terminal_at: Date.now(), + request_error_code: terminal.state === "failed" ? terminal.errorCode : null, + }, + }) + if (!finalized) { + const current = yield* db + .select({ + state: SessionToolRequestReceiptTable.provider_state, + responseFingerprint: SessionToolRequestReceiptTable.response_fingerprint, + }) + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + .pipe(Effect.orDie) + if (current?.state !== terminal.state || current.responseFingerprint !== responseFingerprint) + return yield* Effect.die( + new Error(`provider response receipt terminal replay diverged: ${receiptID}`), + ) + } + receiptFinalizer.value = undefined + }) + receiptFinalizer.value = finalizeReceipt + const turnSettled = { value: false } + const settleProviderTurn = () => + Effect.gen(function* () { + if (turnSettled.value) return + yield* finalizeReceipt() + turnSettled.value = true + // Summary diffs mutate user-message metadata. Run them only after the Provider + // receipt is terminal so cancellation cannot strand an admitted request. + if (step === 1 && !finalizerMode) + yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore) + }) + const prepareAdapterReceipt = (input: { + finalRequestHash: string + promptCacheKey?: string + finalOfferedToolIds: readonly string[] + toolDefinitionHash: string + }) => + db + .transaction( + (tx) => + Effect.gen(function* () { + const current = yield* tx + .select() + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) + .get() + if (!current) return yield* Effect.die(new Error(`provider receipt is missing: ${receiptID}`)) + if (current.provider_state === "prepared") { + if ( + current.final_request_hash !== input.finalRequestHash || + (current.prompt_cache_key ?? undefined) !== input.promptCacheKey || + current.tool_definition_hash !== input.toolDefinitionHash || + stableJson(current.final_offered_tool_ids) !== stableJson(input.finalOfferedToolIds) + ) + return yield* Effect.die( + new Error(`provider adapter preparation diverged on retry: ${receiptID}`), + ) + return + } + if (current.provider_state !== "preparing") + return yield* Effect.die( + new Error( + `provider adapter preparation is too late: ${receiptID}: ${current.provider_state}`, + ), + ) + const updated = yield* tx + .update(SessionToolRequestReceiptTable) + .set({ + final_request_hash: input.finalRequestHash, + provider_request_hash: input.finalRequestHash, + prompt_cache_key: input.promptCacheKey ?? null, + final_offered_tool_ids: [...input.finalOfferedToolIds], + tool_definition_hash: input.toolDefinitionHash, + provider_state: "prepared", + adapter_prepared_at: Date.now(), + }) .where( and( - eq(SessionToolArgumentReceiptTable.receipt_id, receiptID), - eq(SessionToolArgumentReceiptTable.layer, "processor_decoded"), - eq(SessionToolArgumentReceiptTable.call_id, input.callID), + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), ), ) - .run(), - ), + .returning({ receiptID: SessionToolRequestReceiptTable.receipt_id }) + .get() + if (!updated) + return yield* Effect.die(new Error(`provider adapter preparation CAS lost: ${receiptID}`)) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + if (providerAttempt && handle.message.providerAttemptID !== providerAttempt.attemptId) { + handle.message.providerAttemptID = providerAttempt.attemptId + yield* sessions.updateMessage(handle.message) + } + const result = yield* handle.process({ + ...streamInput, + requestReceipt: { + prepared: (prepared) => { + return db + .update(SessionToolRequestReceiptTable) + .set({ + permission_filtered_tool_ids: [...prepared.permissionFilteredToolIds], + adapter_tool_capability: prepared.adapterToolCapability, + adapter_lowering_outcome: prepared.adapterLoweringOutcome, + estimated_input_tokens: prepared.budget.estimatedFullRequestTokens, + physical_input_budget: prepared.budget.physicalInputBudget, + reserved_output_tokens: prepared.budget.reservedOutputTokens, + safety_margin_tokens: prepared.budget.safetyMargin, + context_limit_provenance: prepared.budget.provenance, + }) + .where( + and( + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), + ), + ) + .run() + .pipe(Effect.orDie, Effect.asVoid) }, + adapterPrepared: prepareAdapterReceipt, + dispatched: () => + Effect.gen(function* () { + const transitioned = yield* transitionReceipt({ + from: ["prepared"], + to: "dispatching", + values: { request_state: "dispatched", dispatching_at: Date.now() }, + }) + if (transitioned && providerAttempt) yield* providerAttempt.dispatching.pipe(Effect.orDie) + }), + streaming: () => + Effect.gen(function* () { + const transitioned = yield* transitionReceipt({ + from: ["dispatching"], + to: "streaming", + values: { streaming_at: Date.now() }, + }) + if (transitioned && providerAttempt) yield* providerAttempt.streaming.pipe(Effect.orDie) + }), + // Processor cleanup durably completes the assistant after these callbacks. Keep the + // terminal intent in memory until that cleanup and the response fingerprint are ready, + // then commit the receipt and continuation terminal states together below. + settled: () => + Effect.gen(function* () { + receiptTerminal.value = { state: "settled" } + if (providerAttempt) yield* providerAttempt.settled.pipe(Effect.orDie) + }), + failed: (error) => + Effect.gen(function* () { + receiptTerminal.value ??= { + state: "failed", + errorCode: error instanceof Error ? error.name : "provider_error", + } + if (providerAttempt) yield* providerAttempt.failed(error).pipe(Effect.orDie) + }), + rejected: ({ budget, reason }) => + Effect.gen(function* () { + receiptTerminal.value = { state: "failed", errorCode: reason } + yield* db + .update(SessionToolRequestReceiptTable) + .set({ + estimated_input_tokens: budget.estimatedFullRequestTokens, + physical_input_budget: budget.physicalInputBudget, + reserved_output_tokens: budget.reservedOutputTokens, + safety_margin_tokens: budget.safetyMargin, + context_limit_provenance: budget.provenance, + request_state: "rejected", + request_error_code: reason, + }) + .where( + and( + eq(SessionToolRequestReceiptTable.receipt_id, receiptID), + eq(SessionToolRequestReceiptTable.provider_state, "preparing"), + ), + ) + .run() + .pipe(Effect.orDie) + }), + aiSdkInput: (input) => writeArgumentReceipt({ layer: "ai_sdk_input", ...input }), + rawFrame: (input) => writeArgumentReceipt({ layer: "raw_frame", ...input }), + adapterAssembly: (input) => writeArgumentReceipt({ layer: "adapter_assembly", ...input }), + processorDecoded: (input) => writeArgumentReceipt({ layer: "processor_decoded", ...input }), + processorValidation: (input) => + bestEffortReceiptWrite( + "argument:processor_decoded:validation", + db + .update(SessionToolArgumentReceiptTable) + .set({ validation_outcome: input.validationOutcome }) + .where( + and( + eq(SessionToolArgumentReceiptTable.receipt_id, receiptID), + eq(SessionToolArgumentReceiptTable.layer, "processor_decoded"), + eq(SessionToolArgumentReceiptTable.call_id, input.callID), + ), + ) + .run(), + ), }, - providerAttempt, - ) - const observedCallIds = - (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)) - .find((message) => message.info.id === handle.message.id) - ?.parts.flatMap((part) => (part.type === "tool" ? [part.callID] : [])) ?? [] - yield* bestEffortReceiptWrite( - "observed_call_ids", - db - .update(SessionToolRequestReceiptTable) - .set({ call_ids: observedCallIds }) - .where(eq(SessionToolRequestReceiptTable.receipt_id, receiptID)) - .run(), + }) + const response = (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)).find( + (message) => message.info.id === handle.message.id, ) + if (!response) return yield* Effect.die(new Error(`provider response is missing: ${handle.message.id}`)) if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() return "break" as const } if (finalizerMode) { + const hasTextFallback = + finalizerAllowsText && + response.parts.some( + (part) => part.type === "text" && !part.synthetic && !part.ignored && part.text.trim() !== "", + ) + if (hasTextFallback) { + yield* settleProviderTurn() + return "break" as const + } if (!handle.message.error) { handle.message.error = new SessionV1.StructuredOutputError({ message: "Finalizer did not produce valid structured output", @@ -3175,6 +3864,7 @@ export const layer = Layer.effect( }).toObject() yield* sessions.updateMessage(handle.message) } + yield* settleProviderTurn() return "break" as const } @@ -3186,6 +3876,7 @@ export const layer = Layer.effect( retries: 0, }).toObject() yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() return "break" as const } } @@ -3206,6 +3897,7 @@ export const layer = Layer.effect( // this step. We check the CURRENT assistant message's parts (by handle.message.id). const latestMsgs = yield* MessageV2.promptHistoryEffect(sessionID).pipe( Effect.provideService(Database.Service, database), + Effect.orDie, ) const currentAssistantMsg = latestMsgs.findLast( (m) => m.info.role === "assistant" && m.info.id === handle.message.id, @@ -3224,6 +3916,7 @@ export const layer = Layer.effect( retries: structuredFailedAttempts, }).toObject() yield* sessions.updateMessage(handle.message) + yield* settleProviderTurn() yield* slog.warn("structured-output retry cap reached", { attempts: structuredFailedAttempts, retryMax, @@ -3235,16 +3928,19 @@ export const layer = Layer.effect( // correction text appears as a user instruction in the next model context — // not as assistant output (which the model treats with lower compliance). if (fields.length > 0) { + yield* settleProviderTurn() yield* injectTailReminder( sessionID, `[structured-output correction] Your StructuredOutput call did not match the required schema. Required top-level fields: ${fieldList}. Please call StructuredOutput again using EXACTLY these field names.`, lastUser.model, lastUser.agent, + SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, ) } } } + yield* settleProviderTurn() if (result === "stop") return "break" as const if (result === "compact") { // V4.0.1 P0 — a turn-internal hard compaction (the provider signalled overflow mid-stream). @@ -3263,12 +3959,13 @@ export const layer = Layer.effect( model: lastUser.model, auto: true, overflow: !handle.message.finish, + activityID: SessionProcessor.planProtocolActivityID(lastUser.metadata) ?? lastUser.id, }) } return "continue" as const }).pipe( Effect.ensuring(instruction.clear(handle.message.id)), - Effect.onInterrupt(() => finalizeInterruptedAssistant), + Effect.onInterrupt(() => finalizeInterruptedTurn), ) // V4.1 §S1.1 needsFollowUp: the model finished this step (outcome === "break"), but if a steer // arrived while it was running, do NOT exit — loop once more so the top-of-loop drain absorbs @@ -3408,7 +4105,9 @@ export const layer = Layer.effect( return { kind: "steer" as const, delivery: "steer" as const, admitted } }) - const promptAsync: (input: PromptInput) => Effect.Effect = Effect.fn( + const promptAsync: ( + input: PromptInput, + ) => Effect.Effect = Effect.fn( "SessionPrompt.promptAsync", )(function* (input: PromptInput) { const messageID = input.messageID ?? MessageID.ascending() @@ -3424,7 +4123,11 @@ export const layer = Layer.effect( messageID, }).pipe(Effect.provideService(Database.Service, database)) : undefined - if (claim?.kind === "admitted") return + if (claim?.kind === "admitted") + return { + messageID: claim.receipt.messageID, + delivery: claim.receipt.delivery ?? "turn", + } const claimed = claim?.receipt const admittedInput = claimed ? { @@ -3433,7 +4136,7 @@ export const layer = Layer.effect( parts: stableIntentParts(input.parts, claimed.intentID), } : { ...input, messageID } - const admission = yield* Deferred.make() + const admission = yield* Deferred.make() if (claimed) { yield* Effect.suspend(() => SessionPromptIntent.renew({ @@ -3460,7 +4163,7 @@ export const layer = Layer.effect( ).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Deferred.failCause(admission, cause), - onSuccess: () => Deferred.succeed(admission, undefined), + onSuccess: () => Deferred.succeed(admission, receipt), }), Effect.asVoid, ), @@ -3485,7 +4188,7 @@ export const layer = Layer.effect( ), Effect.forkIn(scope, { startImmediately: true }), ) - yield* Deferred.await(admission) + return yield* Deferred.await(admission) }) const loop: (input: LoopInput, onRunning?: Effect.Effect) => Effect.Effect = Effect.fn( @@ -3524,6 +4227,36 @@ export const layer = Layer.effect( ) }) + const wakeCommittedContinuations = (ctx: InstanceContext) => + Effect.runPromise( + Effect.gen(function* () { + const pending = yield* compaction.recoverableContinuations(ctx.project.id) + yield* Effect.forEach( + pending, + (item) => + loop({ sessionID: item.sessionID }).pipe( + Effect.catchCause((cause) => + Effect.logError("committed compaction continuation recovery failed").pipe( + Effect.annotateLogs({ + runID: item.runID, + sessionID: item.sessionID, + messageID: item.messageID, + cause, + }), + ), + ), + Effect.forkIn(scope), + ), + { discard: true }, + ) + }).pipe(Effect.provideService(InstanceRef, ctx)), + ) + const unregisterCompactionRecovery = registerInitializer(wakeCommittedContinuations) + const currentInstance = yield* InstanceRef + if (currentInstance) { + yield* Effect.promise(() => wakeCommittedContinuations(currentInstance)).pipe(Effect.forkIn(scope)) + } + const shell: (input: ShellInput) => Effect.Effect = Effect.fn( "SessionPrompt.shell", )(function* (input: ShellInput) { @@ -3928,6 +4661,7 @@ export const layer = Layer.effect( notificationWorkers.clear() unregisterDurableInitializer() unregisterDurableDisposer() + unregisterCompactionRecovery() const directories = new Set([...durableWorkers.keys(), ...durableLeases.keys()]) yield* Effect.promise(() => Promise.all([...directories].map(disposeDurableWorkers))) }), @@ -4222,8 +4956,18 @@ function stableJson(value: unknown, seen = new WeakSet()): string { return result } +/** @internal Exported for deterministic receipt verification. */ +function providerResponseFingerprint(response: SessionV1.WithParts) { + return Hash.sha256(stableJson(response)) +} + /** @internal Exported for testing */ -export { buildStructuredOutputRuntimeTail, buildStructuredOutputSystemPrompt, extractSchemaTopLevelFields } +export { + buildStructuredOutputRuntimeTail, + buildStructuredOutputSystemPrompt, + extractSchemaTopLevelFields, + providerResponseFingerprint, +} export function createStructuredOutputTool(input: { schema: Record diff --git a/packages/deepagent-code/src/session/reminders.ts b/packages/deepagent-code/src/session/reminders.ts index 2d1c03748..8e0f21478 100644 --- a/packages/deepagent-code/src/session/reminders.ts +++ b/packages/deepagent-code/src/session/reminders.ts @@ -35,9 +35,10 @@ export const renderPlanStatus = ( const plan = AgentGateway.DeepAgentSessionState.getPlan(sessionID) if (!plan) return null - const snapshot = AgentGateway.DeepAgentPlanController.renderPlanSnapshot(plan, detail) const ref = AgentGateway.DeepAgentPlanStore.planDocRef(sessionID) - const precondition = ref ? `\nPlan precondition: plan_id=${plan.plan_id} plan_version=${ref.version}` : "" + const snapshot = ref + ? AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, ref.version, detail) + : `${AgentGateway.DeepAgentPlanController.renderPlanSnapshot(plan, detail)}\nPlan write unavailable: expected_version is unavailable for expected_plan_id=${JSON.stringify(plan.plan_id)}. Do not guess or call advance/replan.` const mutations = AgentGateway.DeepAgentSessionState.mutationsSinceReport(sessionID) const validationPassedSinceReport = AgentGateway.DeepAgentSessionState.validationPassedSinceReport(sessionID) // U10 hybrid trigger: semantic (a validation just passed) is primary, mode-scaled count is the @@ -48,7 +49,7 @@ export const renderPlanStatus = ( mode: agentMode, }) const nudge = trigger ? `\n\n${AgentGateway.DeepAgentPlanController.PROGRESS_NUDGE(trigger, mutations)}` : "" - return `\n${snapshot}${precondition}${nudge}\n` + return `\n${snapshot}${nudge}\n` } export const apply = Effect.fn("SessionReminders.apply")(function* (input: { diff --git a/packages/deepagent-code/src/session/task-delivery.ts b/packages/deepagent-code/src/session/task-delivery.ts index 5d28916db..fc7ed0402 100644 --- a/packages/deepagent-code/src/session/task-delivery.ts +++ b/packages/deepagent-code/src/session/task-delivery.ts @@ -227,6 +227,7 @@ export function admitParentInput(input: { }, metadata: { deepagent: { + planProtocolActivityID: input.item.messageID, task_notification: { run_id: input.item.runID, outbox_id: input.item.id, diff --git a/packages/deepagent-code/src/tool/plan-write.ts b/packages/deepagent-code/src/tool/plan-write.ts index de7c43b62..490243db6 100644 --- a/packages/deepagent-code/src/tool/plan-write.ts +++ b/packages/deepagent-code/src/tool/plan-write.ts @@ -44,15 +44,24 @@ export const PlanEvent = { const PlanStep = Schema.Struct({ step_id: Schema.optional(Schema.String).annotate({ - description: "Stable id; required for advance, omit only when create/replan should allocate a new identity", + description: + "Stable id; required for advance, copy it for an unchanged replan step, and omit it for create or a genuinely new replan step so the server allocates it", + }), + title: Schema.optional(Schema.String).annotate({ + description: "What this step does; required for create/replan and ignored for advance", }), - title: Schema.String.annotate({ description: "What this step does" }), status: Schema.String.annotate({ description: "pending | active | done | cancelled | blocked" }), // No NullOr: a nested optional(NullOr(...)) emits a double-nested anyOf whose inner // {type:null} survives normalize() and is rejected by some third-party providers (no-reply). // Optional already covers "absent"; strict admission normalizes missing values to null. - acceptance: Schema.optional(Schema.String).annotate({ description: "How you know this step is done" }), - assigned_agent: Schema.optional(Schema.String).annotate({ description: "Subagent type to delegate to" }), + acceptance: Schema.optional(Schema.String).annotate({ + description: + "Acceptance criterion for create/replan; when retaining a replan step, omit to copy the authoritative value shown in the correction", + }), + assigned_agent: Schema.optional(Schema.String).annotate({ + description: + "Subagent type for create/replan; when retaining a replan step, omit to copy the authoritative value shown in the correction", + }), note: Schema.optional(Schema.String).annotate({ description: "Short note; REQUIRED when status is 'blocked' — say why you are stuck", }), @@ -62,15 +71,31 @@ export const Parameters = Schema.Struct({ operation: Schema.Literals(["create", "advance", "replan"]).annotate({ description: "create a plan, advance an existing plan, or replan with a reason", }), - expected_plan_id: Schema.NullOr(Schema.String), - expected_version: Schema.NullOr(NonNegativeInt), - replan_reason: Schema.optional(Schema.String), - goal: Schema.String.annotate({ description: "One sentence: what 'done' means for this task" }), - steps: Schema.mutable(Schema.Array(PlanStep)).annotate({ description: "Ordered plan steps" }), + expected_plan_id: Schema.NullOr(Schema.String).annotate({ + description: + "Use null for create; for advance/replan copy expected_plan_id exactly from the latest or plan result", + }), + expected_version: Schema.NullOr(NonNegativeInt).annotate({ + description: + "Use null for create; for advance/replan copy expected_version exactly from the latest or plan result", + }), + replan_reason: Schema.optional(Schema.String).annotate({ + description: "Required for replan; omit for create/advance", + }), + goal: Schema.optional(Schema.String).annotate({ + description: "One sentence: what 'done' means for this task; required for create/replan", + }), + steps: Schema.mutable(Schema.Array(PlanStep)).annotate({ + description: + "Ordered plan steps for create/replan; for advance copy existing step_id values from and send status/note updates", + }), assumptions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "Facts the plan relies on", + description: "Facts for create; for replan omit to retain the authoritative list, or send [] to clear it", + }), + active_step_id: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "For create/replan, omit this field and mark at most one step active; the server derives its allocated ID. For advance, copy a visible step_id, omit to retain it, or use null to clear it", }), - active_step_id: Schema.NullOr(Schema.String).annotate({ description: "The step currently being worked on" }), }) export const PlanWriteParameters = Parameters @@ -84,6 +109,7 @@ type Metadata = { plan_version?: number plan_attempt_ordinal?: number plan_error_code?: string + plan_error_step_ids?: string[] challenge_id?: string } @@ -98,19 +124,25 @@ export const PlanTool = Tool.define) => ({ + // Advance is a status patch at this boundary. Identity fields are + // server-owned and intentionally excluded from its semantic proposal. + advance_patch: input.operation === "advance", operation: input.operation, expected_plan_id: input.expected_plan_id, expected_version: input.expected_version, - replan_reason: input.replan_reason ?? null, - goal: input.goal.trim(), - assumptions: (input.assumptions ?? []).map((value) => value.trim()), - active_step_id: input.active_step_id, + replan_reason: input.operation === "advance" ? null : (input.replan_reason ?? null), + goal: input.operation === "advance" ? null : (input.goal?.trim() ?? null), + assumptions: input.operation === "advance" ? [] : (input.assumptions ?? []).map((value) => value.trim()), + active_step_id: + input.operation === "advance" && input.active_step_id === undefined + ? "retain" + : (input.active_step_id ?? null), steps: input.steps.map((step) => ({ step_id: step.step_id ?? null, - title: step.title.trim(), + title: input.operation === "advance" ? null : (step.title?.trim() ?? null), status: step.status.trim().toLowerCase(), - acceptance: step.acceptance ?? null, - assigned_agent: step.assigned_agent ?? null, + acceptance: input.operation === "advance" ? null : (step.acceptance ?? null), + assigned_agent: input.operation === "advance" ? null : (step.assigned_agent ?? null), note: step.note ?? null, })), }), @@ -140,16 +172,7 @@ export const PlanTool = Tool.define { const built = AgentGateway.DeepAgentPlanController.buildPlanFromWriteInput( ctx.sessionID, - { - operation: params.operation, - expected_plan_id: params.expected_plan_id, - expected_version: params.expected_version, - replan_reason: params.replan_reason, - goal: params.goal, - steps: params.steps, - assumptions: params.assumptions, - active_step_id: params.active_step_id, - }, + normalizeModelPlanWrite(params, previous, expectedRef), previous, expectedRef, ) @@ -186,32 +209,49 @@ export const PlanTool = Tool.define + const current = AgentGateway.DeepAgentPlanStore.getPlanDoc(ctx.sessionID) + const currentRef = AgentGateway.DeepAgentPlanStore.planDocRef(ctx.sessionID) + const currentProgress = current + ? AgentGateway.DeepAgentPlanController.planProgress(current) + : { done: 0, total: 0 } return { title: "Plan conflict", - output: "The plan changed before this update was committed. Re-read the current plan and retry with its exact plan_id and version.", + output: + "The plan changed before this update was committed. Re-read the current plan and retry with its exact expected_plan_id and expected_version." + + renderPlanRetryBase(current, currentRef), metadata: { - plan_id: conflict.actual?.plan_id ?? previous?.plan_id ?? "", - goal: previous?.goal ?? params.goal, - done: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).done : 0, - total: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).total : 0, + plan_id: conflict.actual?.plan_id ?? current?.plan_id ?? previous?.plan_id ?? "", + goal: current?.goal ?? previous?.goal ?? params.goal ?? "", + done: currentProgress.done, + total: currentProgress.total, plan_protocol: "conflict", plan_error_code: "plan_conflict", - plan_version: conflict.actual?.version ?? ref?.version ?? 0, + plan_version: conflict.actual?.version ?? currentRef?.version ?? ref?.version ?? 0, }, } } if (error instanceof AgentGateway.DeepAgentPlanController.PlanValidationError) { const validation = error + const offending = validation.offending_step_ids + const offendingText = offending.length ? " Offending step IDs: " + offending.join(", ") + "." : "" + const validationOutput = [ + "The plan was not committed (" + validation.code + ").", + offendingText, + " Correct the plan payload and retry once.", + validation.challenge_id ? " Confirmation: " + validation.challenge_id : "", + renderModelPlanCorrection(params.operation, validation.code, previous, ref), + ].join("") return { title: "Plan needs correction", - output: `The plan was not committed (${validation.code}). Correct the plan payload and retry once.${validation.challenge_id ? ` Confirmation: ${validation.challenge_id}` : ""}`, + output: validationOutput, metadata: { plan_id: previous?.plan_id ?? "", - goal: previous?.goal ?? params.goal, + goal: previous?.goal ?? params.goal ?? "", done: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).done : 0, total: previous ? AgentGateway.DeepAgentPlanController.planProgress(previous).total : 0, plan_protocol: "invalid", plan_error_code: validation.code, + ...(offending.length ? { plan_error_step_ids: [...offending] } : {}), ...(validation.challenge_id ? { challenge_id: validation.challenge_id } : {}), ...(ref ? { plan_version: ref.version } : {}), }, @@ -262,33 +302,24 @@ export const PlanTool = Tool.define Effect.logWarning("plan.updated publication failed; snapshot remains authoritative").pipe( - Effect.annotateLogs({ sessionID: ctx.sessionID, plan_id: plan.plan_id, plan_version: version, cause }), + Effect.annotateLogs({ + sessionID: ctx.sessionID, + plan_id: plan.plan_id, + plan_version: version, + cause, + }), Effect.asVoid, ), ), ) } - const lines = plan.steps.map((s) => { - const mark = - s.status === "done" - ? "x" - : s.status === "cancelled" - ? "-" - : s.status === "blocked" - ? "!" - : s.status === "active" - ? ">" - : " " - const suffix = s.status === "blocked" && s.note ? ` — blocked: ${s.note}` : "" - return `[${mark}] ${s.title}${suffix}` - }) const changeSummary = changeLines.length > 0 ? `\n\nChanges: ${changeLines.join("; ")}` : "" const warnSummary = acceptanceWarnings.length > 0 ? `\n\n⚠ ${acceptanceWarnings.join("; ")}. Verify before finalizing.` : "" return { title: `Plan: ${done}/${total} steps`, - output: `Goal: ${plan.goal}\n${lines.join("\n")}${changeSummary}${warnSummary}`, + output: `${AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, version)}${changeSummary}${warnSummary}`, metadata: { plan_id: plan.plan_id, goal: plan.goal, @@ -307,3 +338,188 @@ export const PlanTool = Tool.define }), ) + +// The core controller keeps the full-document contract for human and HTTP writes. Model advances +// use this adapter so compact snapshots and model restatements cannot mutate authoritative identity +// fields; only status, note, and active-step intent crosses the model boundary. +export const normalizeModelPlanWrite = ( + params: Schema.Schema.Type, + previous: ReturnType, + expected: AgentGateway.DeepAgentPlanController.PlanExpected | null, +) => { + // Stale writers are concurrency conflicts even when a concurrent replan also changed step IDs. + // Check the shared core precondition before interpreting the patch against current authority. + AgentGateway.DeepAgentPlanController.requirePlanWriteExpected(params, previous, expected) + const base = { + operation: params.operation, + expected_plan_id: params.expected_plan_id, + expected_version: params.expected_version, + ...(params.replan_reason !== undefined ? { replan_reason: params.replan_reason } : {}), + goal: params.goal ?? "", + } + + if (params.operation === "create" || previous == null) { + // Model-created IDs are never authoritative. Keep explicit null so a contradictory active status + // remains a validation error, and derive every non-null pointer after server allocation. + const deriveActive = params.active_step_id === undefined || params.active_step_id !== null + return { + ...base, + assumptions: params.assumptions, + ...(deriveActive ? {} : { active_step_id: null }), + steps: params.steps.map((step) => ({ ...step, step_id: undefined, title: step.title ?? "" })), + } + } + + if (params.operation === "advance") { + const suppliedIDs = params.steps.map((step) => step.step_id?.trim() ?? "") + if (suppliedIDs.some((stepID) => stepID === "")) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError("unsafe_step_identity", [], previous.plan_id) + } + const duplicateIDs = suppliedIDs.filter((stepID, index) => suppliedIDs.indexOf(stepID) !== index) + if (duplicateIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "duplicate_step_id", + [...new Set(duplicateIDs)], + previous.plan_id, + ) + } + const knownIDs = new Set(previous.steps.map((step) => step.step_id)) + const unknownIDs = suppliedIDs.filter((stepID) => !knownIDs.has(stepID)) + if (unknownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "unsafe_step_identity", + unknownIDs, + previous.plan_id, + ) + } + const updates = new Map(params.steps.map((step, index) => [suppliedIDs[index], step] as const)) + return { + ...base, + goal: previous.goal, + assumptions: [...previous.assumptions], + active_step_id: params.active_step_id === undefined ? previous.active_step_id : params.active_step_id, + steps: previous.steps.map((step) => { + const update = updates.get(step.step_id) + return { + step_id: step.step_id, + title: step.title, + status: update?.status ?? step.status, + acceptance: step.acceptance ?? null, + assigned_agent: step.assigned_agent ?? null, + note: update?.note ?? step.note ?? null, + } + }), + } + } + + const suppliedIDs = params.steps.map((step) => step.step_id?.trim() ?? "") + const duplicateIDs = suppliedIDs.filter((stepID, index) => suppliedIDs.indexOf(stepID) !== index) + const duplicateKnownIDs = duplicateIDs.filter(Boolean) + if (duplicateKnownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "duplicate_step_id", + [...new Set(duplicateKnownIDs)], + previous.plan_id, + ) + } + const knownIDs = new Set(previous.steps.map((step) => step.step_id)) + const unknownIDs = suppliedIDs.filter((stepID) => stepID !== "" && !knownIDs.has(stepID)) + if (unknownIDs.length > 0) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "unsafe_step_identity", + unknownIDs, + previous.plan_id, + ) + } + return { + ...base, + goal: previous.goal, + assumptions: params.assumptions === undefined ? [...previous.assumptions] : params.assumptions, + active_step_id: + params.active_step_id === undefined + ? undefined + : params.active_step_id !== null && + !params.steps.some((step) => step.step_id?.trim() === params.active_step_id?.trim()) && + params.steps.every((step) => (step.step_id?.trim() ?? "") === "") && + !knownIDs.has(params.active_step_id.trim()) && + params.steps.filter( + (step) => AgentGateway.DeepAgentPlanController.normalizePlanStepStatus(step.status) === "active", + ).length === 1 + ? undefined + : params.active_step_id, + steps: params.steps.map((update) => { + const stepID = update.step_id?.trim() ?? "" + const prior = stepID === "" ? undefined : previous.steps.find((step) => step.step_id === stepID) + return { + step_id: stepID === "" ? undefined : stepID, + title: update.title ?? prior?.title ?? "", + status: update.status, + acceptance: update.acceptance ?? prior?.acceptance ?? null, + assigned_agent: update.assigned_agent ?? prior?.assigned_agent ?? null, + note: update.note ?? prior?.note ?? null, + } + }), + } +} + +export const renderModelPlanCorrection = ( + operation: Schema.Schema.Type["operation"], + code: AgentGateway.DeepAgentPlanController.PlanValidationCode, + previous: ReturnType, + ref: ReturnType, +): string => { + if (operation === "advance") return renderPlanRetryBase(previous, ref) + if (code === "plan_already_exists") { + return ( + "\n\nCorrection protocol: create cannot replace an existing plan. Use advance for status/note changes or replan for structural changes, with the exact authoritative precondition below." + + renderPlanRetryBase(previous, ref) + ) + } + if (operation === "create") { + return ( + "\n\nCorrection protocol for create: use " + + JSON.stringify({ expected_plan_id: null, expected_version: null }) + + ". Omit active_step_id; mark at most one step status=active and the server will allocate missing step_id values, then derive active_step_id. Do not invent a future server ID." + ) + } + if (previous == null || ref == null) { + return "\n\nAuthoritative replan parameters are unavailable. Do not guess expected_plan_id, expected_version, step_id, or active_step_id. If no plan exists, use create with null expected values." + } + return ( + "\n\nCorrection protocol for replan: copy the exact precondition below. For a retained step, copy its exact step_id, title, acceptance, and assigned_agent; the server also fills acceptance/assigned_agent when omitted. Omit step_id for every new step so the server allocates it. Omit active_step_id and mark at most one step status=active; the server derives its ID after allocation. Omit assumptions to retain the authoritative list, or send [] only when you intentionally clear it.\n" + + JSON.stringify({ + expected_plan_id: previous.plan_id, + expected_version: ref.version, + assumptions: previous.assumptions, + existing_steps: previous.steps.map((step) => ({ + step_id: step.step_id, + title: step.title, + acceptance: step.acceptance, + assigned_agent: step.assigned_agent, + })), + }) + ) +} + +export const renderPlanRetryBase = ( + previous: ReturnType, + ref: ReturnType, +): string => { + if (previous == null) return "" + if (ref == null) { + return `\n\nAuthoritative plan parameters unavailable: expected_version is unavailable for expected_plan_id=${JSON.stringify(previous.plan_id)}. Do not guess or call advance/replan.` + } + return ( + "\n\nAuthoritative plan parameters (copy expected_* and step_id values exactly; do not infer them):\n" + + JSON.stringify({ + expected_plan_id: previous.plan_id, + expected_version: ref.version, + active_step_id: previous.active_step_id, + steps: previous.steps.map((step) => ({ + step_id: step.step_id, + status: step.status, + ...(step.note != null ? { note: step.note } : {}), + })), + }) + ) +} diff --git a/packages/deepagent-code/src/tool/plan-write.txt b/packages/deepagent-code/src/tool/plan-write.txt index 39e9104f5..f5908c1ee 100644 --- a/packages/deepagent-code/src/tool/plan-write.txt +++ b/packages/deepagent-code/src/tool/plan-write.txt @@ -8,22 +8,35 @@ the committed document as the authority. A rejected payload is never partially a Required protocol fields: - operation: exactly one of create, advance, or replan. - expected_plan_id and expected_version: both null for create; for advance/replan, copy the exact - plan_id and plan_version from the latest plan snapshot/event. -- goal, steps, and active_step_id: the complete proposed plan, not a patch. active_step_id is null - when no step is active. + expected_plan_id and expected_version values shown in the latest , successful plan + result, or authoritative correction result. Never infer either value from history. +- goal and steps: goal is required for create/replan. For create/replan, omit active_step_id and mark + at most one step status=active; after allocating missing step IDs, the server derives the active + ID. Advance may omit goal and active_step_id when the current authoritative values should be + retained. Otherwise copy a visible active_step_id and send one or more status/note updates. - replan_reason: required and specific for replan; omit it for create/advance. Operation rules: -- create is only for a session with no plan. Step IDs may be omitted and are assigned once. -- advance must preserve the existing plan_id, goal, assumptions, ordered step IDs, titles, - acceptance criteria, and assigned agents. It is for status, note, active-step, and evidence - progress within the existing contract. -- replan is an intentional structural revision and must explain why. Do not use it to bypass the - version precondition or to erase unresolved work. Suspicious quality regressions are rejected. +- create is only for a session with no plan. Omit all step IDs; the server assigns them once. +- advance is a status patch. Preserve the existing plan_id and exact version precondition. Step IDs + must be copied exactly from the latest or plan result; never infer them from titles or + array positions. Titles, acceptance criteria, assigned agents, goal, and assumptions are + server-owned and ignored from the advance payload. Send only status, note, and active-step changes. + The server keeps the authoritative step order and identity fields. +- replan is an intentional structural revision and must explain why. Copy an existing step_id only + when that step keeps the same title, acceptance, and assigned agent; the correction payload shows + those authoritative values, and omitted acceptance/assigned_agent fields are filled from it. Omit + step_id for every new step so the server allocates it. Omit active_step_id and use one active status + for server-side derivation. Omit assumptions to retain the current list; send [] only to clear it. + Do not use replan to bypass the version precondition or erase unresolved work. Suspicious regressions + are rejected. - status must be pending, active, done, cancelled, or blocked. A blocked step must include a note. - There may be at most one active step, and active_step_id must agree with the statuses. + There may be at most one active step. For create/replan the server derives active_step_id; for + advance an explicitly supplied active_step_id must agree with the statuses. - Do not provide evidence. Validation and runtime integrations attach evidence only after admission. After a step is finished, call this tool immediately with the next active step and the exact latest -plan identity/version. Keep the plan small and honest. If a write is rejected, inspect the current -snapshot and retry once with a corrected complete payload. +expected_plan_id, expected_version, and step_id values. If any required identity value is unavailable, +do not guess or call advance/replan. Keep the plan small and honest. If a write is rejected, inspect +the current snapshot or authoritative parameters in the tool error and retry once with corrected +status/note fields. Do not repeat an unchanged invalid payload. diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index f50a16769..cdbb622dd 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -117,6 +117,7 @@ export function resolveOutputSchema( const FINALIZER_ATTEMPTS = 2 const FINALIZER_RAW_RESULT_MAX_CHARS = 80_000 +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) // Token usage is provider- and cache-dependent, so it is deliberately not a hard task boundary. The // step, wall-time, no-progress, and output bounds remain the operational safety limits. export const DEFAULT_SUBAGENT_RESEARCH_BUDGET = { @@ -140,6 +141,8 @@ export type SubagentPromptInput = { agent: string agentModeOverride: AgentMode | undefined outputSchema: Record | undefined + /** Permit only the bounded second finalizer to return schema-validated JSON text. */ + allowTextFallback?: boolean directStructuredOutput?: boolean finalizerInstructions?: readonly string[] runID?: string @@ -510,6 +513,25 @@ function validateStructuredOutput(schema: Record, value: unknow ) } +function extractStructuredText(text: string) { + const trimmed = text.trim() + if (!trimmed) return undefined + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim() + const objectStart = trimmed.indexOf("{") + const objectEnd = trimmed.lastIndexOf("}") + const arrayStart = trimmed.indexOf("[") + const arrayEnd = trimmed.lastIndexOf("]") + return [ + trimmed, + fenced, + objectStart !== -1 && objectEnd > objectStart ? trimmed.slice(objectStart, objectEnd + 1) : undefined, + arrayStart !== -1 && arrayEnd > arrayStart ? trimmed.slice(arrayStart, arrayEnd + 1) : undefined, + ] + .filter((candidate): candidate is string => candidate !== undefined) + .map((candidate) => Option.getOrUndefined(decodeJson(candidate))) + .find((candidate) => candidate !== undefined) +} + export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect { return Effect.gen(function* () { const parts = yield* input.ops.resolvePromptParts(input.prompt) @@ -734,6 +756,7 @@ export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect${JSON.stringify(input.outputSchema)}` : "", "", boundedRaw, "", @@ -779,9 +810,8 @@ export function runSubagentPrompt(input: SubagentPromptInput): Effect.Effect