diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index c6dd83c0a4..4779fd0809 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -645,6 +645,16 @@ export function createSessionLaunchRuntime({ supportsVision: visionFromModelConfig(modelConfig), supportedThinkingLevels: [...thinkingCapabilities.supportedThinkingLevels], ...(modelConfig ? { modelConfig } : {}), + // Per-model context/compaction settings ride the launch payload, so a + // settings change applies to the next prompt without a restart (ADR 0301 + // / D447). Absent keeps the runtime's own defaults. + ...(storedModel?.dynamicContext + ? { dynamicContext: storedModel.dynamicContext } + : {}), + ...(storedModel?.earlyCompaction + ? { earlyCompaction: storedModel.earlyCompaction } + : {}), + ...(storedModel?.sleepTime ? { sleepTime: storedModel.sleepTime } : {}), }, pluginTools: [ ...plugins diff --git a/apps/desktop/src/components/ContextUsageInspector.tsx b/apps/desktop/src/components/ContextUsageInspector.tsx index d1e36bb236..e08659a473 100644 --- a/apps/desktop/src/components/ContextUsageInspector.tsx +++ b/apps/desktop/src/components/ContextUsageInspector.tsx @@ -40,6 +40,7 @@ export function ContextUsageInspector({ responseDurationMs, responseOutputTokens, responseOutputEstimated = false, + estimatedOccupancyTokens, }: { usage: MessageUsage; turnUsage: MessageUsage; @@ -48,6 +49,12 @@ export function ContextUsageInspector({ responseDurationMs?: number; responseOutputTokens?: number; responseOutputEstimated?: boolean; + /** + * Post-compaction occupancy from the newest checkpoint, while it is newer + * than the latest usage-bearing message. The ring leads with it; the + * popover's provider rows keep the last real request. + */ + estimatedOccupancyTokens?: number; }) { const { t } = useTranslation(); const panelId = useId(); @@ -63,7 +70,18 @@ export function ContextUsageInspector({ const [open, setOpen] = useState(false); const [popoverPosition, setPopoverPosition] = useState(null); - const context = calculateContextUsage(usage, contextWindow); + // A freshly compacted window has no request usage of its own: lead with the + // checkpoint's estimate until the next provider response lands. + const context = + estimatedOccupancyTokens !== undefined + ? calculateContextUsage( + { totalTokens: estimatedOccupancyTokens } as MessageUsage, + contextWindow, + ) + : calculateContextUsage(usage, contextWindow); + // The estimate is not a measurement: mark it, so the number reads as an + // estimate rather than pretending to be the last request's real usage. + const occupancyPrefix = estimatedOccupancyTokens !== undefined ? "≈" : ""; // The display preference flips the leading figure only; capacity colors // still follow remaining space so the warning state keeps one meaning. const usageDisplay = useAppStore((state) => @@ -98,7 +116,7 @@ export function ContextUsageInspector({ // tooltip contract while `percent`/`count` stay numeric. const ariaArguments = { percent: display.percent, - count: formatCompactTokenCount(display.tokens), + count: occupancyPrefix + formatCompactTokenCount(display.tokens), state: display.display === "used" ? t("chat.usageContextAriaUsed") @@ -261,14 +279,14 @@ export function ContextUsageInspector({ {display.display === "used" ? t("chat.usageContextSpent", { - count: formatCompactTokenCount(display.tokens), + count: occupancyPrefix + formatCompactTokenCount(display.tokens), }) : t("chat.usageContextLeft", { - count: formatCompactTokenCount(display.tokens), + count: occupancyPrefix + formatCompactTokenCount(display.tokens), })} - {display.percent}% + {occupancyPrefix}{display.percent}%
@@ -400,7 +418,7 @@ export function ContextUsageInspector({ /> - {display.percent}% + {occupancyPrefix}{display.percent}% {popover && typeof document !== "undefined" diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx index 3828c2fa52..bd4a228ba9 100644 --- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx +++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx @@ -25,6 +25,22 @@ import { type ModelBinding, type ModelInfo, type SessionThinkingLevel, + DYNAMIC_CONTEXT_DEFAULT_PERCENT, + DYNAMIC_CONTEXT_MAX_PERCENT, + DYNAMIC_CONTEXT_MIN_PERCENT, + EARLY_COMPACTION_DEFAULT_PERCENT, + SLEEP_TIME_MAX_RUNS_PER_HOUR, + SLEEP_TIME_MIN_RUNS_PER_HOUR, + clampEarlyCompactionDelaySeconds, + clampEarlyCompactionPercent, + clampSleepTimeRunsPerHour, + defaultDynamicContextPercent, + defaultEarlyCompaction, + defaultSleepTime, + EARLY_COMPACTION_MAX_DELAY_SECONDS, + EARLY_COMPACTION_MAX_PERCENT, + EARLY_COMPACTION_MIN_DELAY_SECONDS, + EARLY_COMPACTION_MIN_PERCENT, type ThinkingLevel, } from "@pi-desktop/shared"; import { @@ -36,6 +52,8 @@ import { api } from "../../lib/api"; import { Button, Field, HelpIcon, Input, Tooltip, TooltipButton, cx } from "../ui"; import { IconClose, IconGripVertical, IconHelp, IconPlus, IconRefresh, IconSearch } from "../icons"; import { SettingsMenuSelect } from "./SettingsMenuSelect"; +const DYNAMIC_CONTEXT_PRESETS = [50, 60, 75, 90] as const; + import { filterChosenModels, hidesAddedBinding } from "./model-chosen-filter"; import { applyCustomModelLookup, @@ -586,6 +604,28 @@ export function ModelSelectionPanes({ const imageModelSelected = imageModelIds?.some((modelId) => modelIdsMatch(modelId, binding.id), ) ?? false; + // A binding written before the gate existed has no object; absent + // means the shipped behaviour, so the control opens on the + // default gate for this window. + const dynamicEnabled = binding.dynamicContext?.enabled ?? true; + const dynamicPercent = + binding.dynamicContext?.thresholdPercent ?? + defaultDynamicContextPercent(binding.contextWindow); + // A binding written before the background pass existed carries + // no object; absent means the shipped defaults, so the controls + // open on them (75 % of the window / 120 s / silent). + const earlyCompaction = + binding.earlyCompaction ?? defaultEarlyCompaction(); + const earlyEnabled = earlyCompaction.enabled; + const earlyPercent = earlyCompaction.thresholdPercent; + const earlyDelay = earlyCompaction.delaySeconds; + const earlySilent = earlyCompaction.silent; + // A binding written before the digest existed carries no object; + // absent means the shared default, so the control opens off at + // the default quota. + const sleepTime = binding.sleepTime ?? defaultSleepTime(); + const sleepEnabled = sleepTime.enabled; + const sleepRuns = sleepTime.maxRunsPerHour; const advancedId = `model-advanced-${binding.id}`; return (
  • +
    +
    + + {t("settings.dynamicContext")} + + {/* A switch states the gate is on; the slider below + keeps the user's last stop while it is off. */} + + + {t("settings.dynamicContextReadout", { + percent: dynamicPercent, + tokens: formatTokenCount( + Math.round( + (binding.contextWindow * dynamicPercent) / 100, + ), + ), + })} + +
    + + updateBinding(binding.id, { + dynamicContext: { + enabled: dynamicEnabled, + thresholdPercent: Number(event.target.value), + }, + }) + } + /> +
    + {DYNAMIC_CONTEXT_PRESETS.map((preset) => { + const on = dynamicPercent === preset; + return ( + + updateBinding(binding.id, { + dynamicContext: { + enabled: dynamicEnabled, + thresholdPercent: preset, + }, + }) + } + > + {preset}% + + ); + })} +
    +

    + {t("settings.dynamicContextHint")} +

    +
    +
    +
    + + {t("settings.earlyCompaction")} + + {/* A switch states the pass is on; the slider, delay + and silent flag keep the user's last stops while + it is off. */} + + + {t("settings.earlyCompactionReadout", { + percent: earlyPercent, + tokens: formatTokenCount( + Math.round( + (binding.contextWindow * earlyPercent) / 100, + ), + ), + })} + +
    + + updateBinding(binding.id, { + earlyCompaction: { + ...earlyCompaction, + thresholdPercent: clampEarlyCompactionPercent( + Number(event.target.value), + ), + }, + }) + } + /> +
    + + {t("settings.earlyCompactionDelay")} + + + {t("settings.earlyCompactionDelayReadout", { + seconds: earlyDelay, + })} + +
    + + updateBinding(binding.id, { + earlyCompaction: { + ...earlyCompaction, + delaySeconds: clampEarlyCompactionDelaySeconds( + Number(event.target.value), + ), + }, + }) + } + /> +
    + + {t("settings.earlyCompactionSilent")} + + +
    +

    + {t("settings.earlyCompactionHint")} +

    +
    +
    +
    + + {t("settings.sleepTime")} + + {/* A switch states the digest is on; the quota keeps + the user's last stop while it is off. */} + + + {t("settings.sleepTimeReadout", { + runs: sleepRuns, + })} + +
    + + updateBinding(binding.id, { + sleepTime: { + ...sleepTime, + maxRunsPerHour: clampSleepTimeRunsPerHour( + Number(event.target.value), + ), + }, + }) + } + /> +

    + {t("settings.sleepTimeHint")} +

    +
    diff --git a/apps/desktop/src/lib/latest-turn-context.ts b/apps/desktop/src/lib/latest-turn-context.ts index add34979e7..2f2c83e51c 100644 --- a/apps/desktop/src/lib/latest-turn-context.ts +++ b/apps/desktop/src/lib/latest-turn-context.ts @@ -24,6 +24,12 @@ export type LatestTurnContextInspector = { responseDurationMs?: number; responseOutputTokens?: number; responseOutputEstimated: boolean; + /** + * Post-compaction occupancy estimate: present only while the newest + * checkpoint is newer than the latest usage-bearing message, because a + * freshly compacted window has no request of its own yet. + */ + estimatedOccupancyTokens?: number; }; /** @@ -53,6 +59,24 @@ export function latestTurnContextInspector( .reverse() .find((message) => message.usage); + // A freshly installed checkpoint knows its post-compaction occupancy; the + // ring leads with that estimate until a newer request reports usage. + const latestMark = compactions.at(-1); + const boundaryIndex = latestMark + ? parentMessages.findIndex( + (message) => message.id === latestMark.throughMessageId, + ) + : -1; + const usageIndex = latestUsageMessage + ? parentMessages.indexOf(latestUsageMessage) + : -1; + const estimatedOccupancyTokens = + latestMark?.tokensAfter !== undefined && + boundaryIndex >= 0 && + boundaryIndex >= usageIndex + ? Math.max(0, Math.round(latestMark.tokensAfter)) + : undefined; + return { // Occupancy and provider cache/input/output use this last request. // turnUsage remains the visual-turn sum for completed-turn speed. @@ -75,5 +99,8 @@ export function latestTurnContextInspector( responseOutputEstimated: latestTurn ? assistantTurnResponseOutputIsEstimated(latestTurn) : false, + ...(estimatedOccupancyTokens !== undefined + ? { estimatedOccupancyTokens } + : {}), }; } diff --git a/apps/desktop/test/context-compaction.test.mjs b/apps/desktop/test/context-compaction.test.mjs index c12d741c68..24e9034aa0 100644 --- a/apps/desktop/test/context-compaction.test.mjs +++ b/apps/desktop/test/context-compaction.test.mjs @@ -97,7 +97,7 @@ test("the hard boundary is enforced by the host, with a model-side escape hatch" assert.match(runtime, /pendingOverflow/); assert.match(runtime, /runCompaction\(\s*"overflow",\s*true,\s*"active_turn",?\s*\)/); assert.match(runtime, /fallback: "retained_tail"/); - // Codex's tool, verbatim and parameterless, plus its two-tier reminder. + // Codex's tool, verbatim and parameterless, plus its first-tier reminder. assert.match(runtime, /CONTEXT_COMPACTION_TOOL_NAME = "new_context"/); assert.match( runtime, @@ -105,9 +105,14 @@ test("the hard boundary is enforced by the host, with a model-side escape hatch" ); assert.match(runtime, /pendingModelCompaction = true/); assert.match(runtime, /function contextBudgetReminder\(/); - assert.match(runtime, /function contextFallbackReminder\(/); assert.match(runtime, /contextReminderClaimed/); - assert.match(runtime, /contextFallbackReminderClaimed/); + // One tier only (ADR 0300): the sharper second reminder told the model that + // unsummarized detail "will not be available afterwards", which stopped being + // true once compaction became reversible and `recall` could read any earlier + // message back. It is gone rather than rephrased. + assert.doesNotMatch(runtime, /function contextFallbackReminder\(/); + assert.doesNotMatch(runtime, /contextFallbackReminderClaimed/); + // The reminder is a per-turn append, so it never reaches the transcript. // pi 0.86 carries the canonical system prompt in the transcript. The // reminder is appended as a per-turn system message, not only as the legacy // AgentContext.systemPrompt field. @@ -292,13 +297,22 @@ test("every compaction announces itself once, on top of the specific toasts", () }); test("a failed compaction checkpoint still restores a non-empty context", () => { - // A retained-tail fallback must not persist an empty tail on a completed - // turn: with no real summary to carry the boundary, an empty tail restores - // as an empty context after a runtime rebuild (model switch / restart) — - // the session reads as if it had just started (#224). + // A degraded checkpoint must not persist an empty tail on a completed turn: + // with no real summary to carry the boundary, an empty tail restores as an + // empty context after a runtime rebuild (model switch / restart) — the + // session reads as if it had just started (#224). + // + // Both degraded layers (the deterministic record and the retained-tail + // notice) share one selection, so the rule cannot hold in one path and be + // forgotten in the other. assert.match( runtime, - /const retainedTail =\s*preparation\.retainedTail\.length > 0\s*\? preparation\.retainedTail\s*: selectRetainedUserMessages\(/, + /private retainedTailForDegradedCheckpoint\(\s*preparation: ShapedPreparation,\s*\): AgentMessage\[\] \{\s*return preparation\.retainedTail\.length > 0\s*\? preparation\.retainedTail\s*: selectRetainedUserMessages\(/, + ); + assert.equal( + (runtime.match(/retainedTailForDegradedCheckpoint\(preparation\)/g) ?? []) + .length, + 2, ); assert.match( runtime, diff --git a/apps/desktop/test/latest-turn-context.test.mjs b/apps/desktop/test/latest-turn-context.test.mjs index 7c0ae21cb0..87d2f04571 100644 --- a/apps/desktop/test/latest-turn-context.test.mjs +++ b/apps/desktop/test/latest-turn-context.test.mjs @@ -216,3 +216,92 @@ test("latest turn inspector keeps last-request usage beside the turn sum", () => assert.equal(inspector?.turnUsage.cacheReadTokens, 51_500); assert.equal(inspector?.turnUsage.inputTokens, 57_300); }); + +test("latest turn inspector leads with the checkpoint estimate after compaction", () => { + const inspector = latestTurnContextInspector( + [ + message("u1", "user", "overflow"), + message("a1", "assistant", "done", { + providerId: "provider", + modelId: "catalog-model", + usage: { inputTokens: 150_000, outputTokens: 2_000, totalTokens: 152_000 }, + }), + ], + providerModels, + providers, + [ + { + id: "mark-1", + generation: 1, + summaryTokens: 40, + tokensAfter: 24_000, + throughMessageId: "a1", + summarized: true, + }, + ], + ); + + assert.equal(inspector?.estimatedOccupancyTokens, 24_000); + // The real usage stays available for the popover's provider rows. + assert.equal(inspector?.usage.totalTokens, 152_000); +}); + +test("latest turn inspector drops the estimate once newer usage arrives", () => { + const inspector = latestTurnContextInspector( + [ + message("u1", "user", "overflow"), + message("a1", "assistant", "done", { + providerId: "provider", + modelId: "catalog-model", + usage: { inputTokens: 150_000, outputTokens: 2_000, totalTokens: 152_000 }, + }), + message("u2", "user", "after compaction"), + message("a2", "assistant", "fresh", { + providerId: "provider", + modelId: "catalog-model", + usage: { inputTokens: 26_000, outputTokens: 900, totalTokens: 26_900 }, + }), + ], + providerModels, + providers, + [ + { + id: "mark-1", + generation: 1, + summaryTokens: 40, + tokensAfter: 24_000, + throughMessageId: "a1", + summarized: true, + }, + ], + ); + + assert.equal(inspector?.estimatedOccupancyTokens, undefined); + assert.equal(inspector?.usage.totalTokens, 26_900); +}); + +test("latest turn inspector ignores marks without an estimate", () => { + const inspector = latestTurnContextInspector( + [ + message("u1", "user", "overflow"), + message("a1", "assistant", "done", { + providerId: "provider", + modelId: "catalog-model", + usage: { inputTokens: 150_000, outputTokens: 2_000, totalTokens: 152_000 }, + }), + ], + providerModels, + providers, + [ + { + id: "mark-1", + generation: 1, + summaryTokens: 40, + throughMessageId: "a1", + summarized: true, + }, + ], + ); + + assert.equal(inspector?.estimatedOccupancyTokens, undefined); +}); diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 36ac9a85e7..c92ef09504 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -2339,6 +2339,150 @@ async fn handle_request( .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; Ok(json!({ "ok": true })) } + "session.appendSleep" => { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "sessionId required", "INVALID_PARAMS"))?; + let record: crate::transcripts::SleepRecord = serde_json::from_value( + params + .get("record") + .cloned() + .ok_or_else(|| rpc_err(1002, "record required", "INVALID_PARAMS"))?, + ) + .map_err(|e| rpc_err(1002, e.to_string(), "INVALID_PARAMS"))?; + let st = state.lock().await; + sessions::append_sleep(&st.db, session_id, &record) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + Ok(json!({ "ok": true })) + } + "session.recall" => { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "sessionId required", "INVALID_PARAMS"))?; + let query = params.get("query").and_then(|v| v.as_str()); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) + .clamp(1, 100) as usize; + let st = state.lock().await; + let result = + crate::transcripts::recall_transcript(st.db.data_dir(), session_id, query, limit) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + serde_json::to_value(result).map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL")) + } + "session.readMessage" => { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "sessionId required", "INVALID_PARAMS"))?; + let message_id = params + .get("messageId") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "messageId required", "INVALID_PARAMS"))?; + let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let max_chars = (params + .get("maxChars") + .and_then(|v| v.as_u64()) + .unwrap_or(8_000)) + .clamp(1, crate::transcripts::MESSAGE_TEXT_MAX_CHARS as u64) + as usize; + // With a project path, the message is only readable through the + // project the session is bound to (ADR 0300): a foreign pair reads + // as not found, so session existence never leaks across projects. + if let Some(project_path) = params.get("projectPath").and_then(|v| v.as_str()) { + let st = state.lock().await; + let project_id = sessions::project_id_for_path(&st.db, project_path) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))? + .ok_or_else(|| rpc_err(1002, "unknown project", "INVALID_PARAMS"))?; + let bound = sessions::session_bound_to_project(&st.db, session_id, project_id) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + if !bound { + return Err(rpc_err( + 1002, + "session is not bound to this project", + "INVALID_PARAMS", + )); + } + } + let st = state.lock().await; + match crate::transcripts::read_message_text( + st.db.data_dir(), + session_id, + message_id, + offset, + max_chars, + ) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))? + { + Some(window) => serde_json::to_value(window) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL")), + None => Err(rpc_err(1002, "message not found", "INVALID_PARAMS")), + } + } + "search.query" => { + let query = params + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "query required", "INVALID_PARAMS"))?; + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) + .clamp(1, 100); + let project_path = params.get("projectPath").and_then(|v| v.as_str()); + let st = state.lock().await; + let project_id = match project_path { + Some(path) => sessions::project_id_for_path(&st.db, path) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?, + None => None, + }; + let hits = sessions::search_project_messages(&st.db, query, limit as i64, project_id) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + Ok(json!({ "hits": serde_json::to_value(hits).unwrap_or_default() })) + } + "session.readProject" => { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "sessionId required", "INVALID_PARAMS"))?; + let project_path = params + .get("projectPath") + .and_then(|v| v.as_str()) + .ok_or_else(|| rpc_err(1002, "projectPath required", "INVALID_PARAMS"))?; + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) + .clamp(1, 50); + let before_seq = params.get("beforeSeq").and_then(|v| v.as_i64()); + let st = state.lock().await; + let project_id = sessions::project_id_for_path(&st.db, project_path) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))? + .ok_or_else(|| rpc_err(1002, "unknown project", "INVALID_PARAMS"))?; + match sessions::read_project_messages( + &st.db, + session_id, + project_id, + limit as i64, + before_seq, + ) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))? + { + Some(window) => serde_json::to_value(window) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL")), + None => Ok(json!({ + "sessionId": session_id, + "sessionTitle": "", + "total": 0, + "hasMore": false, + "messages": [] + })), + } + } + "session.replaceMessages" => { let session_id = params .get("sessionId") diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index f8e794fb41..b6279f4e5b 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -2126,6 +2126,24 @@ pub fn append_compaction( transcripts::append_compaction(db.data_dir(), session_id, &session_created, compaction) } +pub fn append_sleep( + db: &Database, + session_id: &str, + record: &crate::transcripts::SleepRecord, +) -> Result<()> { + if record.id.trim().is_empty() + || record.digest.trim().is_empty() + || record.through_message_id.trim().is_empty() + { + return Err(anyhow!("invalid sleep record")); + } + // Same guard order as `append_compaction`: the session row must exist before + // any file is touched. The digest never becomes recovery state, so no stamp + // is kept. + session_created_at(db, session_id)?; + crate::transcripts::append_sleep(db.data_dir(), session_id, record) +} + fn compaction_valid_for_records(compaction: &CompactionRecord, records: &[MessageRecord]) -> bool { let Some(through_index) = records .iter() @@ -3650,6 +3668,299 @@ pub fn get_token_usage_history( })) } +/// Characters of tool-result text the read window treats as the stored head +/// (ADR 0300): a tool row longer than this reads back flagged `truncated`, so +/// the page must not present a cut as the whole message. +const TOOL_INDEX_TEXT_MAX_CHARS: usize = 2_000; + +pub fn project_id_for_path(db: &Database, path: &str) -> Result> { + let Some(normalized) = crate::db::canonical_project_path(path) else { + return Ok(None); + }; + let id: Option = db + .conn() + .prepare_cached("SELECT id FROM projects WHERE path = ?1")? + .query_row(params![normalized], |row| row.get(0)) + .optional()?; + Ok(id) +} + +// ---- search ----------------------------------------------------------------- + +/// Search message text across sessions. +/// +/// `project_id` narrows the hits to one project's sessions; `None` keeps the +/// unscoped search the renderer's global search uses. A caller that hands the +/// search to a model must always pass an explicit project (spec +/// 03-runtime/02 §recall). +/// Recency blend for one session's best rank. +/// +/// BM25 is negative and sorts ascending, so a recent session has to be made +/// *more* negative to move up: multiplying by `1 + 0.2 * recency` does that and +/// leaves an old session's rank essentially unscaled. `0.693` is `ln 2`, so the +/// half-life is 30 days. +fn blended_rank(rank: f64, newest_ms: i64, now_ms: i64) -> f64 { + let age_days = ((now_ms - newest_ms).max(0) as f64) / 86_400_000.0; + let recency = (-0.693 * age_days / 30.0).exp(); + rank * (1.0 + 0.2 * recency) +} + +/// Search message text across sessions; the model reaches it through +/// `recall_project` (D424). +/// +/// Ranking follows the community `recall` implementation: the best BM25 rank +/// per session wins, a 30-day half-life recency blend promotes recent work, and +/// candidates are over-fetched 3× so the blend can lift a session that ranked +/// below the cut. The result is **one hit per session**, with the snippet taken +/// from that session's best-matching row. The tool's next step is to read one +/// session in full, so a session that matches in twenty places is one result +/// with a way in, not twenty rows that bury the other sessions. +pub fn search_project_messages( + db: &Database, + query: &str, + limit: i64, + project_id: Option, +) -> Result> { + let query = query.trim(); + if query.is_empty() { + return Ok(Vec::new()); + } + let limit = limit.clamp(1, 100); + // Over-fetch so the recency blend can promote a recent session that ranked + // below the requested cut. + let candidate_limit = limit.saturating_mul(3); + // FTS5 reads a bare hyphen as the NOT operator, so every query goes in as + // one quoted phrase: `ask-codex` stays a phrase instead of becoming + // `ask NOT codex`. Trigram FTS needs >= 3 chars; shorter queries use LIKE. + let use_fts = query.chars().count() >= 3; + let fts_query = format!("\"{}\"", query.replace('"', "\"\"")); + let like_query = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + + let map_rank = |row: &rusqlite::Row<'_>| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, f64>(1)?, + row.get::<_, i64>(2)?, + )) + }; + let ranked: Vec<(String, f64, i64)> = if use_fts { + let mut stmt = db.conn().prepare_cached( + // `MIN(rank)` — not `MIN(bm25(...))`: the FTS5 auxiliary function + // fails at row-fetch time inside an aggregate ("unable to use + // function bm25 in the requested context"), while the special + // `rank` column aggregates fine and is the same number. + "SELECT m.session_id, MIN(rank) AS best_rank, + MAX(m.created_at) AS newest + FROM messages_fts + JOIN messages m ON m.mid = messages_fts.rowid + JOIN sessions s ON s.id = m.session_id + WHERE messages_fts MATCH ?1 + AND (?3 IS NULL OR s.project_id = ?3) + GROUP BY m.session_id + ORDER BY best_rank + LIMIT ?2", + )?; + let rows = stmt.query_map(params![fts_query, candidate_limit, project_id], map_rank)?; + rows.collect::>>()? + } else { + // LIKE ranks nothing, so a flat rank leaves the recency blend as the + // only ordering: newest session first, which is what this branch is for. + let mut stmt = db.conn().prepare_cached( + "SELECT m.session_id, -1.0 AS best_rank, MAX(m.created_at) AS newest + FROM messages m + JOIN sessions s ON s.id = m.session_id + WHERE m.text LIKE '%' || ?1 || '%' ESCAPE '\\' + AND (?3 IS NULL OR s.project_id = ?3) + GROUP BY m.session_id + ORDER BY newest DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![like_query, candidate_limit, project_id], map_rank)?; + rows.collect::>>()? + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0); + let mut blended: Vec<(String, f64)> = ranked + .into_iter() + .map(|(session_id, rank, newest)| (session_id, blended_rank(rank, newest, now_ms))) + .collect(); + blended.sort_by(|left, right| left.1.total_cmp(&right.1)); + blended.truncate(limit as usize); + + let map_hit = |row: &rusqlite::Row<'_>| { + Ok(SearchHit { + message_id: row.get(0)?, + session_id: row.get(1)?, + session_title: row.get(2)?, + role: row.get(3)?, + created_at: ms_to_ts(row.get(4)?), + snippet: row.get(5)?, + }) + }; + let mut hits = Vec::with_capacity(blended.len()); + for (session_id, _) in blended { + // The representative row: this session's best-ranked match, so the + // snippet is the one that explains why the session surfaced at all. + let hit = if use_fts { + let mut stmt = db.conn().prepare_cached( + "SELECT m.id, m.session_id, s.title, m.role, m.created_at, + snippet(messages_fts, 0, '', '', '…', 16) + FROM messages_fts + JOIN messages m ON m.mid = messages_fts.rowid + JOIN sessions s ON s.id = m.session_id + WHERE messages_fts MATCH ?1 AND m.session_id = ?2 + ORDER BY rank + LIMIT 1", + )?; + let mut rows = stmt.query_map(params![fts_query, session_id], map_hit)?; + rows.next().transpose()? + } else { + let mut stmt = db.conn().prepare_cached( + "SELECT m.id, m.session_id, s.title, m.role, m.created_at, + substr(m.text, 1, 160) + FROM messages m + JOIN sessions s ON s.id = m.session_id + WHERE m.text LIKE '%' || ?1 || '%' ESCAPE '\\' + AND m.session_id = ?2 + ORDER BY m.created_at DESC + LIMIT 1", + )?; + let mut rows = stmt.query_map(params![like_query, session_id], map_hit)?; + rows.next().transpose()? + }; + if let Some(hit) = hit { + hits.push(hit); + } + } + Ok(hits) +} + +/// One message row inside a project-scoped read window (D424). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMessageRow { + pub id: String, + pub seq: i64, + pub role: String, + pub text: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + /// The text below is **not the whole message** (ADR 0300). Two independent + /// cuts can make that true: the window's own 4,000-character body cap, and + /// — for a tool row — the stored text being the 2,000-character index head. + /// Either way the rest is readable through `session.readMessage` by id, so + /// one flag is enough: a browse page has to say when what it shows is not + /// everything, and it must not claim more than it can verify. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, +} + +/// A bounded window over one session's transcript, readable only through the +/// project the session is bound to. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMessageWindow { + pub session_id: String, + pub session_title: String, + pub total: i64, + pub has_more: bool, + /// Oldest → newest inside the page, even though pages walk newest-first. + pub messages: Vec, +} + +/// Read a bounded window of one session's messages, scoped to a project. +/// +/// Whether `session_id` is bound to `project_id`. +/// +/// The project-scoped message read (ADR 0300) uses this so a caller that +/// supplies both a project path and a foreign session id gets the same +/// "not found" it would get for a session that does not exist — session +/// existence never leaks across projects, exactly like the read window below. +pub fn session_bound_to_project(db: &Database, session_id: &str, project_id: i64) -> Result { + let found: Option = db + .conn() + .prepare_cached("SELECT 1 FROM sessions WHERE id = ?1 AND project_id = ?2")? + .query_row(params![session_id, project_id], |row| row.get(0)) + .optional()?; + Ok(found.is_some()) +} + +/// The model-facing sibling of [`search_messages`] (D424): a session is only +/// readable when it is bound to `project_id`, so the sidecar's read tool can +/// never reach another project's history even though the caller supplies the +/// session id. Pages walk backwards through `before_seq` (exclusive) and +/// `has_more` reports whether older messages remain. `None` means the session +/// does not exist or belongs to a different project — callers map that to an +/// empty window so session existence never leaks. +pub fn read_project_messages( + db: &Database, + session_id: &str, + project_id: i64, + limit: i64, + before_seq: Option, +) -> Result> { + let limit = limit.clamp(1, 50); + let session_title: Option = db + .conn() + .prepare_cached("SELECT title FROM sessions WHERE id = ?1 AND project_id = ?2")? + .query_row(params![session_id, project_id], |row| row.get(0)) + .optional()?; + let Some(session_title) = session_title else { + return Ok(None); + }; + let total: i64 = db + .conn() + .prepare_cached("SELECT COUNT(*) FROM messages WHERE session_id = ?1")? + .query_row(params![session_id], |row| row.get(0))?; + let mut stmt = db.conn().prepare_cached( + "SELECT id, seq, role, COALESCE(substr(text, 1, 4000), ''), created_at, tool_name, + COALESCE(LENGTH(text), 0) + FROM messages + WHERE session_id = ?1 AND (?2 IS NULL OR seq < ?2) + ORDER BY seq DESC LIMIT ?3", + )?; + let rows = stmt.query_map(params![session_id, before_seq, limit + 1], |row| { + let role: String = row.get(2)?; + let stored_chars: i64 = row.get(6)?; + let text: String = row.get(3)?; + // A tool row's stored text is the index head by construction, so "may + // have been cut on the way in" is "is at the cap"; the window's own cap + // is the other cut, and it shows up as the returned text being shorter + // than the stored one. Counted here, in characters, because JavaScript + // string lengths would disagree on astral characters. + let head_only = role == "tool" && stored_chars >= TOOL_INDEX_TEXT_MAX_CHARS as i64; + let truncated = head_only || stored_chars > text.chars().count() as i64; + Ok(ProjectMessageRow { + id: row.get(0)?, + seq: row.get(1)?, + role, + text, + created_at: ms_to_ts(row.get(4)?), + tool_name: row.get(5)?, + truncated, + }) + })?; + let mut messages = rows.collect::>>()?; + let has_more = messages.len() as i64 > limit; + messages.truncate(limit as usize); + // Rows arrive newest-first for the LIMIT; a page reads oldest-first. + messages.reverse(); + Ok(Some(ProjectMessageWindow { + session_id: session_id.to_string(), + session_title, + total, + has_more, + messages, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -3702,6 +4013,7 @@ mod tests { first_kept_message_id: Some(first.into()), through_message_id: through.into(), tokens_before: 120_000, + tokens_after: None, usage: None, retained_tail: Some(json!([{ "role": "user", @@ -3715,6 +4027,79 @@ mod tests { } } + #[test] + fn project_search_reports_one_hit_per_session_ranked_by_best_match() { + let db = test_db(); + let session = + create_session(&db, None, None, None, None, Some("C:/work/alpha".into())).unwrap(); + let other = + create_session(&db, None, None, None, None, Some("C:/work/alpha".into())).unwrap(); + let foreign = + create_session(&db, None, None, None, None, Some("C:/work/beta".into())).unwrap(); + append_message( + &db, + &session.id, + &user_msg("m1", "the spinner plan", "2026-09-16T15:42:41Z"), + None, + ) + .unwrap(); + append_message( + &db, + &session.id, + &user_msg("m2", "spinner spinner spinner", "2026-09-16T15:42:42Z"), + None, + ) + .unwrap(); + append_message( + &db, + &other.id, + &user_msg("m3", "spinner once", "2026-09-16T15:42:43Z"), + None, + ) + .unwrap(); + append_message( + &db, + &foreign.id, + &user_msg("m4", "spinner elsewhere", "2026-09-16T15:42:44Z"), + None, + ) + .unwrap(); + + let project_id = project_id_for_path(&db, "C:/work/alpha").unwrap().unwrap(); + let hits = search_project_messages(&db, "spinner", 10, Some(project_id)).unwrap(); + assert_eq!( + hits.len(), + 2, + "one hit per matching session of this project" + ); + // The session whose best row carries the term most often ranks first. + assert_eq!(hits[0].session_id, session.id); + assert!(hits.iter().all(|hit| hit.session_id != foreign.id)); + } + + #[test] + fn project_read_never_leaks_a_foreign_session() { + let db = test_db(); + let session = + create_session(&db, None, None, None, None, Some("C:/work/alpha".into())).unwrap(); + append_message( + &db, + &session.id, + &user_msg("m1", "alpha work", "2026-09-16T15:42:41Z"), + None, + ) + .unwrap(); + let alpha = project_id_for_path(&db, "C:/work/alpha").unwrap().unwrap(); + create_session(&db, None, None, None, None, Some("C:/work/beta".into())).unwrap(); + let beta = project_id_for_path(&db, "C:/work/beta").unwrap().unwrap(); + + let same = read_project_messages(&db, &session.id, alpha, 50, None).unwrap(); + assert!(same.is_some()); + assert_eq!(same.unwrap().messages.len(), 1); + // A foreign project reads the session as absent, never as an error. + let cross = read_project_messages(&db, &session.id, beta, 50, None).unwrap(); + assert!(cross.is_none()); + } #[test] fn compaction_survives_restart_and_late_truncation_but_not_early_truncation() { let db = test_db(); diff --git a/crates/host-core/src/transcripts.rs b/crates/host-core/src/transcripts.rs index d0f3bf2974..d93acffd3b 100644 --- a/crates/host-core/src/transcripts.rs +++ b/crates/host-core/src/transcripts.rs @@ -65,6 +65,15 @@ pub struct CompactionRecord { pub first_kept_message_id: Option, pub through_message_id: String, pub tokens_before: i64, + /// Occupancy the checkpoint itself believes the next request will carry, + /// stamped when the checkpoint is installed (`persistCheckpoint`). The + /// visible transcript is untouched by a compaction, so nothing else on disk + /// records what the model context shrank to — without this the context ring + /// keeps showing the pre-compaction request until the next one lands. + /// Optional: a transcript written before this field existed loads with + /// `None`, and a line carrying it stays readable by older readers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_after: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -78,6 +87,27 @@ pub struct CompactionRecord { pub created_at: String, } +/// A deterministic sleep-time digest of where the session stood when a +/// background compaction armed (ADR 0301). Deterministic means no model call is +/// involved: the runtime extracts it from the transcript. +/// +/// The line is informational. Nothing reads it as recovery state, and a layout +/// scan never counts it as a message or a checkpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SleepRecord { + /// `sleep--` — unique per write. + pub id: String, + /// The deterministic goal/unresolved/progress text. + pub digest: String, + /// Newest message the digest covers. + pub through_message_id: String, + /// Message lines in the transcript when it was taken. + pub message_count: usize, + /// ISO-8601, same shape as other records. + pub created_at: String, +} + /// One archived regenerate branch rooted at a user turn. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -312,6 +342,8 @@ fn sniff_line_kind(line: &str) -> Option<&'static str> { return match value { b"message" => Some("message"), b"compaction" => Some("compaction"), + + b"sleep" => Some("sleep"), b"session" => Some("session"), b"revision" => Some("revision"), b"revision_live" => Some("revision_live"), @@ -679,6 +711,19 @@ pub fn append_compaction( .with_context(|| format!("append compaction {}", path.display())) } +/// Append a sleep-time digest without rewriting visible messages. It is +/// informational only: the layout skips it, and no reader treats it as recovery +/// state (ADR 0301). +pub fn append_sleep(data_dir: &Path, session_id: &str, record: &SleepRecord) -> Result<()> { + let path = transcript_path(data_dir, session_id)?; + append_line( + &path, + Some(header_line(session_id, "1970-01-01T00:00:00Z")?), + tagged("sleep", record)?, + ) + .with_context(|| format!("append sleep {}", path.display())) +} + /// Load the live transcript. A missing file is an empty transcript; unknown /// line types and a torn trailing line are skipped, not errors. pub fn read_transcript(data_dir: &Path, session_id: &str) -> Result> { @@ -1198,12 +1243,606 @@ pub fn remove_session_files(data_dir: &Path, session_id: &str) { } } +/// One recall hit: a message whose text matched the query. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecallHit { + pub id: String, + pub role: String, + pub created_at: String, + /// Zero-based position of the message in transcript file order. + pub index: usize, + /// Bounded window around the first match (search), or leading text (tail). + pub snippet: String, +} + +/// Recall result plus the session's total message count, so a model can +/// tell when it has seen the whole history. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecallResult { + pub hits: Vec, + pub total_messages: usize, +} + +/// A message's searchable text: the top-level `text` fields of its canonical +/// blocks. Thinking and tool payloads are out of scope for P1 recall. +fn recall_text(blocks: &Value) -> String { + let mut out = String::new(); + if let Some(items) = blocks.as_array() { + for block in items { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); + } + } + } + out +} + +/// Query words: whitespace-separated, case-folded (ASCII bytes for ASCII words, +/// Unicode for the rest), de-duplicated in the order written. Empty input means +/// "no query", which reads the tail. +fn recall_terms(query: &str) -> Vec { + let mut terms: Vec = Vec::new(); + for word in query.split_whitespace() { + // ASCII words keep the byte-level fold they always had; a non-ASCII + // word folds through Unicode, because ASCII folding cannot see that + // `ПРИВЕТ` and `привет` are the same word. + let term = if word.is_ascii() { + word.to_ascii_lowercase() + } else { + word.to_lowercase() + }; + if !terms.contains(&term) { + terms.push(term); + } + } + terms +} + +/// ASCII case-insensitive substring search that copies nothing. +/// +/// The previous path lowercased the whole message before searching, which +/// allocated a copy of every message examined on every query — transcripts are +/// dominated by tool output and reach tens of megabytes. Folding case per +/// candidate byte is the same comparison (ASCII-only, exactly as before) +/// without the copy: case never folds across a multi-byte character, so a +/// byte-wise scan is equivalent for ASCII and non-ASCII needles alike. +fn find_ignore_ascii_case(haystack: &str, needle: &str) -> Option { + if needle.is_empty() { + return Some(0); + } + let hay = haystack.as_bytes(); + let nee = needle.as_bytes(); + if nee.len() > hay.len() { + return None; + } + (0..=hay.len() - nee.len()) + .find(|start| hay[*start..*start + nee.len()].eq_ignore_ascii_case(nee)) +} + +/// One query word against one line of text: the ASCII path stays the byte scan +/// every ASCII query already took; a non-ASCII word folds per character on both +/// sides, because ASCII folding cannot see a Cyrillic or accented case pair. +fn find_ignore_case(haystack: &str, needle: &str) -> Option { + if needle.is_ascii() { + find_ignore_ascii_case(haystack, needle) + } else { + find_ignore_case_unicode(haystack, needle) + } +} + +/// Char-wise case-insensitive search for non-ASCII needles, returning the byte +/// offset of the first matched character. +/// +/// Folding is per `char`, not per byte: `to_lowercase` can map one character to +/// several (`İ` → `i̇`) or to a different byte length, so the folded streams are +/// compared character by character. This path allocates the folded needle and +/// re-slices the haystack per start position — the price of correct folding for +/// the non-ASCII queries that need it, which ASCII queries never take. +fn find_ignore_case_unicode(haystack: &str, needle: &str) -> Option { + let folded_needle: Vec = needle.chars().flat_map(char::to_lowercase).collect(); + if folded_needle.is_empty() { + return Some(0); + } + for (start, _) in haystack.char_indices() { + let mut matched = 0usize; + let mut mismatched = false; + for character in haystack[start..].chars() { + for folded in character.to_lowercase() { + if matched >= folded_needle.len() || folded_needle[matched] != folded { + mismatched = true; + break; + } + matched += 1; + } + if mismatched || matched == folded_needle.len() { + break; + } + } + if !mismatched && matched == folded_needle.len() { + return Some(start); + } + } + None +} + +/// True when a term must appear literally in the raw JSON line, so a line that +/// does not contain it can be skipped without parsing (and without a copy). +/// +/// A term carrying a JSON escape (`"`, `\`) or a control character is written +/// into the line in its escaped form, where the raw bytes differ from the term +/// itself; the pre-filter would then report a false negative, so it is disabled +/// for the whole query when any term is like that. +fn raw_scan_is_conclusive(terms: &[String]) -> bool { + terms.iter().all(|term| { + // Raw-byte matching is ASCII-only: a non-ASCII term folds in a way the + // bytes do not show, so the whole query gives up the pre-filter instead + // of reporting a false negative. + term.is_ascii() + && !term.is_empty() + && !term.contains(['"', '\\']) + && !term.chars().any(char::is_control) + }) +} + +/// Case-insensitive search over a session's full transcript. +/// +/// Session isolation is host-enforced: the transcript path derives from +/// `session_id` alone, so a caller can only recall its own session. An empty or +/// blank query reads the tail: the newest `limit` messages, oldest-first, with +/// leading-text snippets. A query returns the newest `limit` hits first, each +/// with a bounded snippet around its first match. +/// +/// A query is read as **words**: a message matches when every word appears in +/// it, so `retry budget` finds a message that says "the retry spent its budget" +/// even though the phrase never appears. A single word behaves exactly as the +/// old substring search did, and hit order stays newest-first either way. +/// +/// Two costs were paid on every call before and are not any more: the tail read +/// now seeks to the newest messages through the layout instead of parsing the +/// whole file, and a query skips JSON parsing for any line whose raw bytes +/// cannot contain the words. +pub fn recall_transcript( + data_dir: &Path, + session_id: &str, + query: Option<&str>, + limit: usize, +) -> Result { + let terms = query.map(recall_terms).unwrap_or_default(); + if terms.is_empty() { + return recall_tail(data_dir, session_id, limit); + } + recall_matches(data_dir, session_id, &terms, limit) +} + +/// Read the trimmed line at a known message offset, or `None` at end of file. +/// +/// Shared by the two recall walks so both report the same coordinate space: +/// an index is a position among message *lines*, and a line that cannot be +/// parsed drops out of the hits without shifting the indexes around it. +fn read_message_line_at<'a>( + reader: &mut BufReader, + offset: u64, + line: &'a mut String, + path: &Path, +) -> Result> { + reader + .seek(SeekFrom::Start(offset)) + .with_context(|| format!("seek {}", path.display()))?; + line.clear(); + if reader + .read_line(line) + .with_context(|| format!("read {}", path.display()))? + == 0 + { + return Ok(None); + } + let trimmed = line.trim(); + Ok(if trimmed.is_empty() { + None + } else { + Some(trimmed) + }) +} + +/// Tail read: the newest `limit` messages, oldest-first, by seeking to their +/// offsets instead of parsing the whole file. +fn recall_tail(data_dir: &Path, session_id: &str, limit: usize) -> Result { + let layout = refresh_layout(data_dir, session_id, TranscriptLayout::default())?; + let total = layout.message_count(); + if total == 0 || limit == 0 { + return Ok(RecallResult { + hits: Vec::new(), + total_messages: total, + }); + } + let path = transcript_path(data_dir, session_id)?; + let file = match File::open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecallResult { + hits: Vec::new(), + total_messages: 0, + }) + } + Err(e) => return Err(e).with_context(|| format!("read {}", path.display())), + }; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut hits: Vec = Vec::new(); + for index in total.saturating_sub(limit)..total { + if line.capacity() > 1024 * 1024 { + line = String::new(); + } + let Some(trimmed) = + read_message_line_at(&mut reader, layout.message_offsets[index], &mut line, &path)? + else { + continue; + }; + let Ok(record) = serde_json::from_str::(trimmed) else { + tracing::warn!(path = %path.display(), index, "skipping invalid message line"); + continue; + }; + hits.push(RecallHit { + id: record.id.clone(), + role: record.role.clone(), + created_at: record.created_at.clone(), + index, + snippet: recall_text(&record.blocks).chars().take(400).collect(), + }); + } + Ok(RecallResult { + hits, + total_messages: total, + }) +} + +/// How many candidate messages the ranked walk collects before ordering: a +/// small multiple of `limit` with a floor and a ceiling, so a broad query pays +/// a bounded amount of extra parsing and a narrow one pays almost nothing. +const RECALL_RANK_WINDOW_PER_HIT: usize = 6; +const RECALL_RANK_WINDOW_MIN: usize = 40; +const RECALL_RANK_WINDOW_MAX: usize = 200; +/// Occurrences counted per query word; a message repeating one word forever +/// must not outrank one that covers the whole query. +const RECALL_RANK_MAX_TERM_HITS: u32 = 8; + +/// Match strength of one message: how many occurrences of how many query words +/// it carries, counted with the same case folding the match itself uses. The +/// score ranks candidates against each other; it is never returned to the +/// model, so its scale is private. +fn match_score(text: &str, terms: &[String]) -> u32 { + let mut score = 0u32; + for term in terms { + let mut from = 0usize; + let mut seen = 0u32; + while seen < RECALL_RANK_MAX_TERM_HITS { + let Some(at) = text + .get(from..) + .and_then(|rest| find_ignore_case(rest, term)) + else { + break; + }; + seen += 1; + // Advance past the first character of the match: a folded match can + // differ from the term in bytes, but it always starts on a + // character boundary. + let step = text[from + at..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + from += at + step; + } + score += seen; + } + score +} + +/// Message-line search: every match inside the ranking window, best first. +/// +/// The reverse walk goes through the layout's offsets, so the peak cost is the +/// snippets it returns plus the layout — not a vector of every hit. Candidates +/// are collected newest-first up to `window`, then ordered by match strength +/// with recency as the tie-break, so the model reads the most relevant message +/// first without the walk having to scan the whole transcript. One deliberate +/// difference from the old path: `total_messages` and every hit's `index` count +/// message *lines* (the layout's coordinate space) rather than successfully +/// parsed records, so a corrupt line is counted, keeps its slot, and never +/// matches where it used to shift everything after it. +fn recall_matches( + data_dir: &Path, + session_id: &str, + terms: &[String], + limit: usize, +) -> Result { + let layout = refresh_layout(data_dir, session_id, TranscriptLayout::default())?; + let total = layout.message_count(); + let path = transcript_path(data_dir, session_id)?; + let file = match File::open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecallResult { + hits: Vec::new(), + total_messages: 0, + }) + } + Err(e) => return Err(e).with_context(|| format!("read {}", path.display())), + }; + let conclusive = raw_scan_is_conclusive(terms); + let mut reader = BufReader::new(file); + let mut line = String::new(); + let window = limit + .saturating_mul(RECALL_RANK_WINDOW_PER_HIT) + .max(RECALL_RANK_WINDOW_MIN) + .min(RECALL_RANK_WINDOW_MAX); + let mut hits: Vec = Vec::new(); + let mut scores: Vec = Vec::new(); + for index in (0..total).rev() { + if line.capacity() > 1024 * 1024 { + line = String::new(); + } + let Some(trimmed) = + read_message_line_at(&mut reader, layout.message_offsets[index], &mut line, &path)? + else { + continue; + }; + if conclusive + && !terms + .iter() + .all(|term| find_ignore_case(trimmed, term).is_some()) + { + continue; + } + let Ok(record) = serde_json::from_str::(trimmed) else { + tracing::warn!(path = %path.display(), index, "skipping invalid message line"); + continue; + }; + let text = recall_text(&record.blocks); + // The pre-filter above is an optimization on the raw line; the + // authoritative check runs on the parsed text, because a term carrying + // a non-ASCII character (or one JSON escapes) cannot be matched + // against raw bytes — and a query means every word, not any word. + if !terms + .iter() + .all(|term| find_ignore_case(&text, term).is_some()) + { + continue; + } + let Some((pos, len)) = terms + .iter() + .filter_map(|term| find_ignore_case(&text, term).map(|at| (at, term.len()))) + .min_by_key(|(at, _)| *at) + else { + continue; + }; + let start = text.floor_char_boundary(pos.saturating_sub(120)); + let end = text.ceil_char_boundary((pos + len).saturating_add(280).min(text.len())); + hits.push(RecallHit { + id: record.id.clone(), + role: record.role.clone(), + created_at: record.created_at.clone(), + index, + snippet: text[start..end].to_string(), + }); + scores.push(match_score(&text, terms)); + if hits.len() >= window { + break; + } + } + // Order without cloning: take the collected hits, then move each one out of + // its slot in ranked order (match strength first, then recency). + if hits.len() > 1 { + let mut order: Vec = (0..hits.len()).collect(); + order.sort_by(|left, right| { + scores[*right] + .cmp(&scores[*left]) + .then(hits[*right].index.cmp(&hits[*left].index)) + }); + let mut slots: Vec> = + std::mem::take(&mut hits).into_iter().map(Some).collect(); + hits = order + .into_iter() + .map(|slot| slots[slot].take().expect("each ordered index appears once")) + .collect(); + } + hits.truncate(limit); + Ok(RecallResult { + hits, + total_messages: total, + }) +} + +/// Default and maximum size of one [`read_message_text`] page. +pub const MESSAGE_TEXT_DEFAULT_CHARS: usize = 8_000; +pub const MESSAGE_TEXT_MAX_CHARS: usize = 20_000; + +/// One bounded page of a single message's text, read from the transcript. +/// +/// `recall` answers "where does this string appear"; this answers "what does +/// this message say". Both read the append-only transcript — the only place +/// tool text lives, since the index row deliberately stores none for tool rows +/// — so a message the working window narrowed away stays readable page by page +/// (ADR 0300). Offsets are in characters, never bytes, so a page boundary can +/// never split a multi-byte character. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageTextWindow { + pub message_id: String, + pub role: String, + pub created_at: String, + /// Zero-based position of the message in transcript file order. + pub index: usize, + /// Characters in the whole message, so a caller can page until it has all. + pub total_chars: usize, + /// Character offset this page starts at (clamped to `total_chars`). + pub offset: usize, + pub text: String, + pub has_more: bool, + /// Where the next page starts, in characters. Returned rather than derived + /// by the caller: the host counts characters while JavaScript string + /// lengths count UTF-16 units, and the two disagree on astral characters. + pub next_offset: usize, +} + +/// Read one page of one message's text. `None` means the session or the +/// message id is not in this transcript (a missing session reads as empty, +/// exactly like `recall_transcript`). +pub fn read_message_text( + data_dir: &Path, + session_id: &str, + message_id: &str, + offset: usize, + max_chars: usize, +) -> Result> { + let read = read_transcript_window(data_dir, session_id, 0, None)?; + let Some((index, record)) = read + .messages + .iter() + .enumerate() + .find(|(_, record)| record.id == message_id) + else { + return Ok(None); + }; + let text = recall_text(&record.blocks); + let total_chars = text.chars().count(); + let offset = offset.min(total_chars); + let page: String = text.chars().skip(offset).take(max_chars).collect(); + let page_chars = page.chars().count(); + Ok(Some(MessageTextWindow { + message_id: record.id.clone(), + role: record.role.clone(), + created_at: record.created_at.clone(), + index, + total_chars, + offset, + text: page, + has_more: offset + page_chars < total_chars, + next_offset: offset + page_chars, + })) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::tempdir; + #[test] + fn sleep_append_is_append_only_and_never_counts_as_a_message() { + let dir = tempdir().unwrap(); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:00Z", + &record("m1", "one"), + ) + .unwrap(); + let record = SleepRecord { + id: "sleep-1".into(), + digest: "## Goal\nthe ask".into(), + through_message_id: "m1".into(), + message_count: 1, + created_at: "2026-07-26T00:00:02Z".into(), + }; + append_sleep(dir.path(), "s1", &record).unwrap(); + + // A digest append must not rewrite earlier lines... + let layout = refresh_layout(dir.path(), "s1", TranscriptLayout::default()).unwrap(); + assert_eq!(layout.message_count(), 1, "a sleep line is not a message"); + // ...and the line classifies as its own kind, not as unknown noise. + let path = transcript_path(dir.path(), "s1").unwrap(); + let last = std::fs::read_to_string(path) + .unwrap() + .lines() + .last() + .unwrap() + .to_string(); + assert_eq!(sniff_line_kind(&last), Some("sleep")); + assert_eq!(read_transcript(dir.path(), "s1").unwrap().len(), 1); + } + + #[test] + fn recall_matches_every_word_and_ranks_by_strength() { + let dir = tempdir().unwrap(); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:00Z", + &record("m1", "the retry spent its budget"), + ) + .unwrap(); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:01Z", + &record("m2", "unrelated talk"), + ) + .unwrap(); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:02Z", + &record("m3", "retry budget retry budget retry"), + ) + .unwrap(); + + let result = recall_transcript(dir.path(), "s1", Some("retry budget"), 10).unwrap(); + assert_eq!(result.total_messages, 3); + // m3 carries the query words most often, so it ranks first despite being newest-tied. + assert_eq!(result.hits[0].id, "m3"); + assert_eq!(result.hits.len(), 2, "m2 matches neither word"); + } + + #[test] + fn recall_reads_a_non_ascii_word_without_ascii_folding() { + let dir = tempdir().unwrap(); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:00Z", + &record("m1", "код вернулся"), + ) + .unwrap(); + let upper = recall_transcript(dir.path(), "s1", Some("КОД"), 10).unwrap(); + assert_eq!( + upper.hits.len(), + 1, + "a Cyrillic case pair must fold through Unicode" + ); + } + + #[test] + fn read_message_text_pages_forward_in_characters() { + let dir = tempdir().unwrap(); + let long = "д".repeat(50); + append_message( + dir.path(), + "s1", + "2026-07-26T00:00:00Z", + &record("m1", &long), + ) + .unwrap(); + let page = read_message_text(dir.path(), "s1", "m1", 10, 20) + .unwrap() + .expect("found"); + assert_eq!(page.offset, 10); + assert_eq!(page.text.chars().count(), 20); + assert!(page.has_more); + assert_eq!(page.next_offset, 30); + let rest = read_message_text(dir.path(), "s1", "m1", 30, 100) + .unwrap() + .expect("found"); + assert_eq!(rest.text.chars().count(), 20); + assert!(!rest.has_more); + assert!(read_message_text(dir.path(), "s1", "nope", 0, 10) + .unwrap() + .is_none()); + } #[test] fn append_after_torn_tail_terminates_the_torn_line_first() { let dir = tempdir().unwrap(); @@ -1255,6 +1894,7 @@ mod tests { first_kept_message_id: Some("m1".into()), through_message_id: "m2".into(), tokens_before: 42_000, + tokens_after: Some(21_000), usage: Some(json!({ "input": 100, "output": 20 })), retained_tail: Some(json!([{ "role": "user", "content": "again", "timestamp": 1 }])), details: None, @@ -1607,6 +2247,36 @@ mod tests { assert_eq!(restored[0].id, "compact-1"); assert_eq!(restored[0].through_message_id, "m2"); assert_eq!(restored[0].tokens_before, 42_000); + // The post-compaction estimate rides the record so the context ring can + // report the new window without waiting for the next provider request. + assert_eq!(restored[0].tokens_after, Some(21_000)); + } + + /// A checkpoint written before the post-compaction estimate existed must + /// keep loading. The field is optional on the wire, so an old line has no + /// `tokensAfter` and readers that predate it ignore the new one. + #[test] + fn a_checkpoint_without_a_post_compaction_estimate_still_loads() { + let dir = tempdir().unwrap(); + let path = transcript_path(dir.path(), "s1").unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let header = header_line("s1", "2026-07-26T00:00:00Z").unwrap(); + let legacy = json!({ + "type": "compaction", + "id": "legacy-1", + "summary": "summary", + "throughMessageId": "m1", + "tokensBefore": 42_000, + "createdAt": "2026-07-26T00:00:02Z" + }) + .to_string(); + std::fs::write(&path, format!("{header}\n{legacy}\n")).unwrap(); + + let restored = read_compactions(dir.path(), "s1").unwrap(); + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].id, "legacy-1"); + assert_eq!(restored[0].tokens_before, 42_000); + assert_eq!(restored[0].tokens_after, None); } #[test] diff --git a/docs/adr/0300-reversible-compaction-boundary-with-recall.md b/docs/adr/0300-reversible-compaction-boundary-with-recall.md new file mode 100644 index 0000000000..87b55c3b72 --- /dev/null +++ b/docs/adr/0300-reversible-compaction-boundary-with-recall.md @@ -0,0 +1,144 @@ +# ADR 0300: The compaction boundary is reversible (recall over the transcript) + +- Status: Accepted for implementation +- Date: 2026-09-21 +- Deciders: PI-Desktop core +- Amends: ADR 0064 (extends its checkpoint contract with a readable boundary) +- Related: ADR 0030 (hard boundary), ADR 0049 (retained-tail recovery), + ADR 0136 (active task boundary), ADR 0282 (summary retry and sizing), + ADR 0299 (delegate context budget) + +## Context + +ADR 0064 makes a checkpoint final for the model: after compaction the model +context is the summary plus, at most, one retained user message, and every +other row stays only in the visible transcript. The runtime keeps a synchronous +copy of pi's session-context projection (`buildSessionContext`, +`packages/agent-runtime/src/session-context.ts:39`) which renders exactly that: +the newest checkpoint's summary, then the retained tail. + +Two consequences followed from that design and neither was ever decided: + +- The second budget reminder told the model the opposite of the truth once a + summary existed. `contextFallbackReminder()` fired at 2,000 remaining tokens + with "unsummarized detail will not be available afterwards" — a sentence about + a one-way door, in a session whose transcript is complete on disk and whose + checkpoint records the range it covers. +- A checkpoint's opaque `details` carried the summary and the retained tail, and + nothing else. After restart, the only account of what a boundary covered was + the summary text itself, so a model asked about a symbol, file or command that + the summary compressed away had no read path and no way to know the data was + still there. + +The visible transcript has always been complete: compaction never rewrites a +message row (spec 16 §2, `aggregate checkpoint truncation never rewrites`). The +missing piece was a read path, not durability. + +## Decision + +1. **The model can read its own transcript back.** Two tools are registered in + the **core** tool set: `recall` (`RECALL_TOOL_NAME`, + `packages/agent-runtime/src/recall-tools.ts:31`) searches the session's + complete transcript, and `recall_project` searches every session of the + bound project. They are core rather than on-demand because a capability the + model has to discover is one it will not reach for while recovering a detail + a summary dropped. + +2. **The host owns the read semantics.** Search splits the query into words and + requires every word to appear; ranking is by how many query words matched and + how often. Reads page one message by character offset + (`read_message_text`, `crates/host-core/src/transcripts.rs:1660`), which is + how a tool result — absent from the word index — is read back. Matching is + literal on non-ASCII text: a CJK query matches the CJK string without ASCII + folding. The tool descriptions state this rule instead of leaving the model + to infer it, and an empty answer says what to try instead. + +3. **Host reads back the state that the edge calls happened.** `recall_transcript` + (`crates/host-core/src/transcripts.rs:1409`), `read_message_text`, + `search_project_messages` (`crates/host-core/src/sessions.rs:3704`) and + `read_project_messages` (`crates/host-core/src/sessions.rs:3888`) are exposed + through five RPC methods — `session.recall`, `session.readMessage`, + `search.query`, `session.readProject` and `session.appendSleep` + (`crates/host-core/src/rpc/mod.rs:2274`–`:2397`). Project search resolves a + session's project from the stored binding, so a session bound to another + project reads as not found and session existence never leaks across projects. + +4. **A `sleep` transcript line is a durable digest, not a message.** + `SleepRecord` (`crates/host-core/src/transcripts.rs:98`) and `append_sleep` + (`:717`) add one line kind. The layout scan ignores it and it never counts as + a message, so a digest can be recorded without changing message counts, + pagination or the transcript projection. + +5. **A checkpoint carries a mechanical ledger of what it covers.** The record's + opaque `details.ledger` (`packages/agent-runtime/src/runtime.ts:1006`, + written by `buildLedger`, `:6436`) holds file names read and modified, + commands, message and tool-call counts, the goal and the unresolved items. + The projection renders it as a bounded block after the summary, so the next + window keeps an account of the boundary that is one `recall` call away. A + checkpoint written before the ledger existed projects the recall pointer and + nothing else. + +6. **The projection states the read path it actually has.** `RECALL_POINTER` + (`packages/agent-runtime/src/session-context.ts:39`) names the tools and the + fact that the complete transcript is readable. It is appended because the + tools exist; the sentence and the mechanism are the same decision. + +7. **One budget reminder.** The second reminder is removed rather than + rephrased: its sentence is false here. The remaining reminder asks the model + to write durable state down at `clamp(hardLimit * 0.15, 8k, 32k)`; the + rollover and fallback wording names where the messages went and how to read + them again instead of telling the model the transcript is available "to the + user". The hard boundary always warns. + +8. **A failed summary still describes its range.** When summary generation + fails, both degraded layers build the checkpoint through one shared helper + and describe the range they cover instead of persisting a notice with an + empty tail. An empty tail can never be persisted for a completed turn: it + would restore as an empty context after a runtime rebuild. + +9. **Optional fields stay optional.** `tokensAfter` and the ledger live behind + optional record fields (`packages/shared/src/types/sessions.ts`), and + `contextCompactionMark` (`packages/shared/src/context-compaction.ts:47`) + carries only what the renderer needs, so old checkpoints, old marks and old + readers keep working without a migration. + +## Consequences + +- A model that lost a detail to a summary can ask for it again. The summary + stops being the only surviving account of the compacted range. +- The core tool set grows by two tools, which costs prompt tokens for every + session. The alternative — a discoverable tool — was rejected because the + moment of need is exactly when a model is least likely to search for one. +- Recall reads are host-side file scans, not provider requests: reading a + compacted-away message back is cheap compared with re-running the work. +- Word-AND search misses paraphrases. That is a real limit of the lexical + channel and is stated in the tool description; a semantic channel was + measured and deliberately not included. +- Removing the sharper reminder removes a warning. The remaining reminder and + the hard boundary keep the pre-boundary warning path; only the sentence that + was untrue is gone. +- The ledger is derived from the transcript at checkpoint time, so it describes + what happened, not what the summary chose to mention. It is bounded text, so + it cannot grow without limit. + +## Not decided here / out of scope + +- **Semantic or embedding recall.** Measured separately and frozen: it needs a + model asset and a host channel for a gain the lexical channel already gets on + the queries that matter here. +- **Recall across projects.** `recall_project` is scoped to the session's bound + project; there is no cross-project search surface. +- **Transcript presentation.** No new row kind for a recall result; the answer + reaches the model only. The `sleep` line is not rendered as a message. +- **Auto-summarizing recalled spans.** A recalled message is returned as text; + the model decides what to keep. +- **Compaction of the recalled text.** Recall is a tool result and participates + in the normal tool-result limits; there is no second compaction mechanism. + +## References + +- `docs/adr/0064-codex-parity-context-compaction.md` +- `docs/adr/0030-*`, `docs/adr/0049-*`, `docs/adr/0136-*`, `docs/adr/0282-*` +- `docs/spec/03-runtime/02-agent-runtime.md` §5.1 +- `docs/spec/03-runtime/06-host-rpc-protocol.md` §4 Sessions +- `docs/spec/06-delivery/04-e2e-test-plan.md` — E2E-CONTEXT-recall-reads-a-compacted-away-message diff --git a/docs/adr/0301-per-model-context-pressure-settings.md b/docs/adr/0301-per-model-context-pressure-settings.md new file mode 100644 index 0000000000..fdbe77367e --- /dev/null +++ b/docs/adr/0301-per-model-context-pressure-settings.md @@ -0,0 +1,137 @@ +# ADR 0301: Per-model context pressure settings and a silent idle compaction pass + +- Status: Accepted for implementation +- Date: 2026-09-21 +- Deciders: PI-Desktop core +- Amends: ADR 0064 (adds a configurable pressure point and an off-critical-path + pass); extends ADR 0030 (the hard boundary itself is unchanged) +- Related: ADR 0049 (retained-tail recovery), ADR 0136 (active task boundary), + ADR 0282 (summary retry and sizing), ADR 0299 (delegate context budget), + ADR 0300 (reversible boundary with recall) + +## Context + +Every threshold that decides *when* context protection acts was a constant. +The hard limit derives from the model window (`contextBudget`, +`packages/agent-runtime/src/runtime.ts`), tool results are bounded by per-tool +limits (spec 16), and compaction runs inline at the boundary or when the model +calls `new_context` (ADR 0064). + +That is the right default and it is not right for every model. A 1M-token window +and a 128k window reach pressure at very different points of *work remaining*, +and a session that is already going to compact pays for it either way. The only +decision left to the user was whether to keep working until the hard boundary, +where the summary request lands on the critical path, or to spend it while idle. + +Two more facts were unaddressed: + +- Tool results were bounded per result but never as a set, so a long session + carried whole old tool outputs whose content the task had already moved past. +- A summary request that never returns leaves the next window with nothing that + describes where the work stood. + +## Decision + +1. **Three settings live on the model binding.** `ModelBinding` gains optional + `dynamicContext {enabled, thresholdPercent}` (`packages/shared/src/types/models.ts:137`), + `earlyCompaction {enabled, thresholdPercent, delaySeconds, silent}` (`:184`) and + `sleepTime {enabled, maxRunsPerHour}` (`:239`). Absent means the shipped + defaults, so every binding written before this change keeps its behavior. + +2. **Defaults are window-aware where the window is known.** The narrowing gate + defaults to `100% − max(100k, 15% × window) / window`, clamped to 40–95 + (`defaultDynamicContextPercent`, `:156`); a flat 60 is used only when no + window is known (`DYNAMIC_CONTEXT_DEFAULT_PERCENT`, `:148`). Early compaction + defaults to 75 % of the hard limit after 120 s of idleness, silent + (`EARLY_COMPACTION_DEFAULT_*`, `:196`–`:202`), and the sleep digest is off by + default with a quota of 2 runs per hour (`SLEEP_TIME_DEFAULT_RUNS_PER_HOUR`, + `:247`). Both the settings controls and the runtime clamp against the same + exported bounds. + +3. **One gate decides the pressure point.** `dynamicContextGate()` + (`packages/agent-runtime/src/runtime.ts:5903`) is the single source of the + threshold. The outgoing request (`:2047`), the hard-boundary check and the + idle pass all read it, so what narrowing saves is visible to compaction + instead of compaction firing while real room remained. With the gate off, + nothing is narrowed. + +4. **Tool-result tiering sits behind that gate.** Old tool results are shortened + to a head plus a recovery pointer only once the outgoing view is under + pressure (`narrowToolResults`, `packages/agent-runtime/src/tool-result-tier.ts`). + Results for files the session re-opened stay whole + (`workingSetPathsFrom`, `:340`), so the newest file-touching calls define the + working set. The full text of every narrowed result remains reachable through + the pointer the pass embeds, and per-tool limits (spec 16) are unchanged. + +5. **The idle pass runs off the critical path and yields to the user.** + `scheduleIdleCompaction` (`runtime.ts:5936`) arms when a run settles; the + timer fires only after `delaySeconds` without new activity, and any new prompt + sets the stand-down flag. The pass re-checks the same narrowed view before it + spends a summary request (`runIdleCompaction`, `:5959`), so narrowing + postpones it rather than hiding it. + +6. **Silence covers one channel.** A *successful* idle pass emits + `compaction_end` with `idle` and `silent` (`runtime.ts:6369`); the renderer + skips the routine toast. The transcript row, the context inspector, the + checkpoint record and the recall surface are unaffected. A degraded or failed + pass and the inline hard-boundary path always warn; `silent: false` restores + the toast. + +7. **The digest is taken before the attempt, not after it.** The idle arm writes + a deterministic digest of the session's state through + `session.appendSleep` (`runtime.ts:6018`, `takeSleepDigest` `:5956`): a + `sleep` transcript line built by the same model-free pass the degradation + ladder uses (ADR 0300 clause 8). It costs no provider request and exists even + when the summary request never returns. + +8. **The settings reach the runtime the way other model settings do.** They are + persisted with the binding, carried on the launch payload + (`apps/desktop/electron/main/runtime/session-launch.ts:635`–`:638`) and + editable in Settings → Models → Advanced, where each binding row gains the + three controls and their read-outs. + +9. **The hard boundary stays fixed.** It is the safety net on the critical path + and is not configurable (ADR 0030). These settings move the point at which + *optional* work happens, never the point at which a request is refused. + +## Consequences + +- A long session can shed old tool output before the boundary instead of + compacting at it, so the expensive pass happens while the user is not waiting. +- The number the user sees and the number the runtime compares must be the same + one. Both now come from the shared bounds; a settings read-out that disagrees + with the gate is a bug, not a display choice. +- Narrowing is lossy for the model but reversible for the user: the pointer keeps + the full text one call away (ADR 0300). +- A silent successful pass removes a toast some users used as a cue. The row, + the inspector and the record still show the checkpoint, and the failed paths + still warn. +- The window-aware default means two models in the same session cross the gate + at different absolute token counts. That is the intent, and it is why the + read-out prints the share rather than only the percentage. +- The digest spends bounded transcript space; the hourly quota bounds it. + +## Not decided here / out of scope + +- **Recalibrating the estimate.** The gate compares an estimate against the hard + limit; making that estimate track measured request sizes is a separate change + with its own safety review. +- **Per-session overrides.** These settings are per model binding; there is no + session-level override. +- **Automatic tuning.** No adaptive threshold; the defaults are derived from the + window and stay put unless the user moves them. +- **Semantic narrowing.** Tiering is deterministic text surgery, not a model + call. +- **Subagents.** Delegates derive their own budget from their own model and do + not carry these settings (ADR 0299). + +## References + +- `docs/adr/0064-codex-parity-context-compaction.md` +- `docs/adr/0030-*`, `docs/adr/0136-*`, `docs/adr/0282-*`, `docs/adr/0299-*`, + `docs/adr/0300-reversible-compaction-boundary-with-recall.md` +- `docs/spec/03-runtime/02-agent-runtime.md` §5.1 +- `docs/spec/03-runtime/16-tool-result-limits.md` §6a +- `docs/spec/04-ux/06-settings-ia.md` §2 Model configuration +- `docs/spec/06-delivery/04-e2e-test-plan.md` — E2E-CONTEXT-idle-compaction-is-silent-and-yields, + E2E-CONTEXT-tool-results-tier-under-pressure, E2E-SETTINGS-per-model-context-thresholds diff --git a/docs/adr/README.md b/docs/adr/README.md index 4293dffbe3..5bf7ada621 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -328,6 +328,8 @@ Each ADR includes: | 0297 | [Provider-hosted web search as an adapter capability](0297-provider-hosted-web-search-adapter-capability.md) | Accepted | | 0298 | [The app ships no fonts](0298-remove-bundled-fonts.md) | Accepted (D598; amends ADR 0083 / D232) | | 0299 | [Subagent context budget and delegate compaction](0299-subagent-context-budget.md) | Accepted for implementation (amends ADR 0064; extends ADR 0062 / ADR 0279) | +| 0300 | [The compaction boundary is reversible (recall over the transcript)](0300-reversible-compaction-boundary-with-recall.md) | Accepted for implementation (amends ADR 0064; D604) | +| 0301 | [Per-model context pressure settings and a silent idle compaction pass](0301-per-model-context-pressure-settings.md) | Accepted for implementation (amends ADR 0064; extends ADR 0030; D605) | | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | | provider-display-order | [Provider display order](provider-display-order.md) | Accepted | | registry-header-variable-spelling | [Remote header variables accept the registry's `{name}` spelling](registry-header-variable-spelling.md) | Proposed | diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 072655cf24..2a5d528b35 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -668,10 +668,11 @@ type AgentEvent = | { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; ok: boolean; tokensBefore?: number; firstKeptMessageId?: string; - willRetry: boolean; fallback?: "retained_tail"; + willRetry: boolean; fallback?: "retained_tail"; + idle?: boolean; silent?: boolean; mark?: { id: string; throughMessageId: string; generation: number; summaryTokens: number; - summarized: boolean; + summarized: boolean; tokensAfter?: number; fallback?: "retained_tail" }; error?: { code: string; message: string } } | { type: "error"; error: AppError } diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index 95dda728a1..8d2d83758a 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -366,7 +366,7 @@ final report from being misclassified as progress. See E2E-146a. -### 5.1 Context checkpoint protection (D158/D203, ADR 0030/0049/0061/0064) +### 5.1 Context checkpoint protection (D158/D203, ADR 0030/0049/0061/0064/0300/0301) The complete visible transcript and the model context are separate views of the same session. A durable checkpoint summarizes older model context while @@ -385,8 +385,11 @@ not a linked or copied dependency. Compaction follows Codex's mechanism (ADR 0064): it always happens inline at a turn boundary, the model can request it through `new_context`, every compaction -adds a transcript row and raises one warning toast, and there is no -pre-computation anywhere. +adds a transcript row and raises one warning toast, and no summary is +pre-computed ahead of the boundary. The boundary is reversible (ADR 0300), and +an idle pass the model binding enables may run the same compaction off the +critical path (ADR 0301); that pass is not pre-computation of a future boundary +because it re-checks the budget before it spends its summary request. pi 0.84.4+ invokes `prepareNextTurn` only when the loop will start another assistant turn in the same run — including between a completed tool batch and @@ -467,12 +470,14 @@ and testable. **Model-facing surface.** `new_context` takes no parameters and starts a new context window at the next turn boundary; it never clears or resets environment -state. Two budget reminders are appended to the current turn's system prompt, -each at most once per checkpoint window and reset when a checkpoint is -installed: one when the remaining budget falls to -`clamp(hardLimit * 0.15, 8k, 32k)`, asking the model to start closing out, and -one at 2,000 tokens remaining, telling it to write down whatever must survive. -Neither reminder is persisted or shown in the transcript. +state. `recall` and `recall_project` are registered in the core tool set and read +stored transcript back: a message this boundary moved out of model context, or +one a narrowed result replaced with a pointer, stays readable verbatim +(ADR 0300). One budget reminder is appended to the current turn's system prompt, +at most once per checkpoint window and reset when a checkpoint is installed: +when the remaining budget falls to `clamp(hardLimit * 0.15, 8k, 32k)`, asking the +model to start closing out and to write durable state down. It is not persisted +and not shown in the transcript. The hard boundary is the model context window minus request headroom. Headroom is the maximum of a 16,384-token reserve floor, model maximum output @@ -546,6 +551,40 @@ A delegate (§5f) runs the same derivation against its own resolved model and compacts at its own turn boundaries, without a durable checkpoint chain of its own (ADR 0299). +**Reversible boundary.** The complete transcript and the model context are +separate views, and only the second one is compacted: no checkpoint rewrites, +deletes or hides a stored row. The projection therefore states what the model +can still reach. `RECALL_POINTER` (`session-context.ts:39`) names the two +recall tools, and the checkpoint's `details.ledger` (built by `buildLedger`) +carries the mechanical record of the range — files read and modified, commands, +message and tool-call counts, the goal and the unresolved items — rendered as a +bounded block after the summary. A checkpoint written before the ledger existed +projects the pointer and nothing else. When summary generation fails, the +degraded checkpoint describes its own range through one shared helper and never +persists an empty tail for a completed turn. + +**Idle pre-compaction.** When the model binding enables `earlyCompaction` +(ADR 0301), the runtime arms a pass after a run settles. It fires only after +`delaySeconds` of no new activity; any new prompt clears the timer or sets the +stand-down flag, and the pass re-checks the budget before it spends a summary +request, so narrowing enough room cancels it. It emits the same `compaction_end` +as the inline path with `idle: true`, plus `silent: true` when the pass +succeeded and the setting is on; the renderer then skips the routine toast. The +transcript row, the inspector and the checkpoint record are written either way, +and a degraded or failed pass and the hard boundary always warn. The inline path +is not configurable and does not change. + +**Configurable pressure gate and tool-result tiering.** `dynamicContext` on the +binding decides the share of the hard limit at which old tool results are +shortened to a head plus a recovery pointer (`narrowToolResults`, +`tool-result-tier.ts`); the outgoing request, the hard-boundary check and the +idle pass all measure that same view, so what narrowing saves is visible to +compaction. Results for files the session re-opened stay whole, per-tool limits +(spec 16) are unchanged, and with the gate off no result is narrowed. The sleep +digest (`sleepTime`) writes a deterministic `sleep` transcript line before a +summary attempt, so a request that never returns still leaves the next window +with the goal and the open items; it calls no provider and is off by default. + ## 5b. Operating mode and planning state - Default product mode: **Agent** diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index b434c91d48..96d6751d37 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -487,6 +487,32 @@ oversized/deep payloads. Tool values are sanitized for host-reserved keys. The per-plugin rolling limits are 10 single imports, 5 batch imports, and 20 deletes per 60 seconds. P2/P3 methods are not present in protocol v11. +- `session.recall` — searches one session's complete transcript. The query is + split into words and every word must appear; matches are ranked by how many + query words matched and how often they did. Matching is literal for non-ASCII + text (a CJK query matches the CJK string with no ASCII folding). Returns + excerpted matches with their message ids and accepts a positive `limit`. An + unknown session is `NOT_FOUND`. Additive RPC; no protocol version bump. +- `session.readMessage` — reads one message's text back in character windows: + `offset` is a zero-based character offset and `limit` the page size in + characters. Returns the page, `hasMore`, and the next offset; offsets are + characters, never bytes, so a page boundary cannot split a code point. An + unknown message id returns no page. This is the read path for a tool result, + which the word index does not cover. +- `search.query` — searches across the sessions of one project, resolved from + the calling session's stored project binding. A session bound to another + project reads as not found, so session existence never leaks across projects. + Returns at most one hit per session, ranked by its best match. +- `session.readProject` — the project-scoped counterpart of + `session.readMessage`: the same character-window read for a message in any + session of one project, behind the same project-binding check. +- `session.appendSleep` — appends one `sleep` transcript line (`SleepRecord`): a + deterministic, model-free digest of where a session stands. The layout scan + ignores the line and it never counts as a message, so message counts, + pagination and the transcript projection are unchanged. The runtime writes it + when the model binding enables the sleep digest (ADR 0301); the renderer does + not show it as a message. + ### Stats - `stats.getTokenUsageHistory` — roll up completed `turns` token columns and diff --git a/docs/spec/03-runtime/16-tool-result-limits.md b/docs/spec/03-runtime/16-tool-result-limits.md index 2582b4f36a..eb9e9f009e 100644 --- a/docs/spec/03-runtime/16-tool-result-limits.md +++ b/docs/spec/03-runtime/16-tool-result-limits.md @@ -185,6 +185,23 @@ counts are the stable signals. The UI truncated chip follows `truncated`. 7. Timeout and abort close both output streams only after the complete process tree has been shut down; no orphan process may continue writing output +## 6a. Pressure gate and tiering + +Per-result limits bound one result; the pressure gate bounds the set. When the +model binding enables `dynamicContext` (ADR 0301) and the outgoing view reaches +its threshold share of the hard limit, old tool results are shortened to a head +plus a pointer that says how to read the full text back — the dropped text stays +recoverable through `recall`, so narrowing does not destroy it. + +- the gate measures the same view the outgoing request, the hard-boundary check + and the idle compaction pass see, so what narrowing saves is visible to + compaction; +- results for files the session re-opened stay whole (working-set immunity), as + do the excluded tool families and the module's clear-at-least floor; +- below the threshold, or with the gate off, the view is returned untouched; +- the stored transcript rows are never rewritten, and a truncated result still + carries its marker. + ## 7. Acceptance criteria - [x] oversize Bash output truncates with marker and spills the fuller copy @@ -205,3 +222,6 @@ counts are the stable signals. The UI truncated chip follows `truncated`. - [ ] timeout/abort stops the complete process tree and emits no later chunks - [ ] an oversized parallel result batch compacts to a bounded marked tail, survives restart, and leaves the original transcript results unchanged +- [ ] the pressure gate narrows old tool results only above its threshold, keeps + a re-opened file's result whole, and leaves the full text reachable through the + pointer diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index a752c4507b..69c793230c 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -294,6 +294,14 @@ a usage tab. remain user-owned. The account has no API key field and discovery resolves the stored OAuth login instead. Saving updates the OAuth provider row and keeps the global default model in sync when that account is selected + - the same Advanced disclosure carries the model's context-pressure settings: + a Dynamic context switch with a threshold slider (40–95 %, reading out the + share of the hard limit with its approximate token count), an Early + compaction switch with its threshold, idle delay and silent toggle, and a + Sleep-time digest switch with its hourly quota. Each control writes the + binding and applies to the next prompt without a restart; a binding written + before the settings existed shows the shipped defaults, and the values are + clamped to the same bounds the runtime uses (ADR 0301). - Test connection resolves the account's OAuth authorization and reports a transient success or failure without probing the provider with an API key - **Providers** studio: diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 39a7718352..06194c47e0 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8112,6 +8112,10 @@ identify the platform validation still needed. | F — Persistence (stored model binding array) | E2E-PROVIDER-stored-binding-array-reads-entry-by-entry | | Quality (stored model binding array) | E2E-PROVIDER-stored-binding-array-reads-entry-by-entry | | Quality (unique tool-call ids) | E2E-RUNTIME-unique-tool-call-ids-per-request | +| C / F / Quality — Recall reads the compacted-away context back | E2E-CONTEXT-recall-reads-a-compacted-away-message | +| C / Quality — Idle compaction is silent and yields to a prompt | E2E-CONTEXT-idle-compaction-is-silent-and-yields | +| C / Quality — Old tool results tier under pressure | E2E-CONTEXT-tool-results-tier-under-pressure | +| B / Quality — Per-model context thresholds | E2E-SETTINGS-per-model-context-thresholds | | G — Plugin host lifecycle (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code | | Quality (crash report) | E2E-PLUGIN-crash-report-names-the-exit-code | @@ -14351,6 +14355,76 @@ the latest destination. These assertions measure work counts, not device FPS. and a unique one (identity, no log line). - **Status:** Unit-covered; no end-to-end driver issues a real provider request against a duplicated transcript. +### E2E-CONTEXT-recall-reads-a-compacted-away-message + +- **Preconditions:** A session driven past its context boundary with a + deterministic provider fixture, so a checkpoint with a summary and a ledger is + installed. No real provider credentials or paid API. +- **Steps:** Ask for a token that existed only in a message the checkpoint moved + out of model context. Let the model call `recall` with those words, then + `session.readMessage` for the returned id with a small page size, then repeat + with a CJK query and, from a second session of the same project, with + `recall_project`. +- **Expected:** The recalled text comes back verbatim; paging reports `hasMore` + and the next offset; a non-ASCII query matches without ASCII folding; a session + bound to another project reads as not found; the projected summary carries the + recall pointer and the ledger block; transcript rows are unchanged. +- **Specs linked:** `03-runtime/02-agent-runtime.md` §5.1, + `03-runtime/06-host-rpc-protocol.md` §4 Sessions, ADR 0300 +- **Acceptance:** C (chat and stream), F (persistence), Quality +- **Status:** Host and unit tests cover the read paths; desktop E2E pending a + deterministic boundary fixture + +### E2E-CONTEXT-idle-compaction-is-silent-and-yields + +- **Preconditions:** A binding with `earlyCompaction.enabled` and a short + `delaySeconds`, a deterministic summary fixture, and a settled run above the + early threshold. +- **Steps:** Let the session go idle past the delay. Repeat, but send a prompt + inside the delay window. Re-run once with `silent: false`, and once with a + summary fixture that fails. +- **Expected:** The first run compacts with `compaction_end.idle` and `.silent`, + no routine toast appears, and the transcript row plus the context inspector + still record the checkpoint. A prompt inside the window cancels the pass and no + summary request is issued. `silent: false` restores the toast; a degraded pass + and the hard boundary always warn. +- **Specs linked:** `03-runtime/02-agent-runtime.md` §5.1, ADR 0301 +- **Acceptance:** C (chat and stream), Quality +- **Status:** Unit-covered through the runtime's timer and budget seams; desktop + E2E pending + +### E2E-CONTEXT-tool-results-tier-under-pressure + +- **Preconditions:** A session sized so the outgoing view crosses the + `dynamicContext` gate, including a tool result for a file the session later + re-opens. +- **Steps:** Fill the context with old tool results, then issue a prompt that + re-opens one of those files. Compare the outgoing request with the gate on and + with the gate off. +- **Expected:** With the gate on, old results are shortened to a head plus the + recovery pointer, the re-opened file's result stays whole, and the full text is + still readable through the recall path. With the gate off nothing is narrowed. + Per-tool limits and stored rows are unchanged. +- **Specs linked:** `03-runtime/16-tool-result-limits.md` §6a, ADR 0301 +- **Acceptance:** C (chat and stream), Quality +- **Status:** Unit-covered (`tool-result-tier.test.ts`); desktop E2E pending + +### E2E-SETTINGS-per-model-context-thresholds + +- **Preconditions:** Models destination with one configured binding, isolated + desktop profile. +- **Steps:** Open Advanced for the binding. Move the dynamic-context threshold + below and above its bounds; toggle early compaction and change its delay + outside the accepted range; toggle the sleep digest and push its quota past the + ceiling; save and reopen the pane. Then send a prompt without restarting. +- **Expected:** Values clamp to the shared bounds; the read-out prints the share + of the hard limit with its approximate token count; the settings persist with + the binding and reach the next prompt without a restart; a binding without the + settings shows the defaults. +- **Specs linked:** `04-ux/06-settings-ia.md` §2 Model configuration, ADR 0301 +- **Acceptance:** B (model config), Quality +- **Status:** Desktop contract tests cover the pane's defaults; interactive E2E + pending ### E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 4cc7e2031d..509e21a0a9 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -6514,6 +6514,45 @@ that was sitting at the bottom — including after the turn had finished. - The guard is deliberately the request boundary rather than the history rebuild: it also covers a duplicate that appears while the session runs, which a rebuild-time filter cannot see. +## 2026-09-21 — A compaction boundary is reversible and one reminder tier remains (D604) + +- `recall` and `recall_project` join the **core** tool set. They read the stored + transcript back through `session.recall`, `session.readMessage`, + `search.query` and `session.readProject`, so a message a checkpoint moved out + of model context — or one a narrowed tool result replaced with a pointer — is + readable verbatim. Search requires every query word to appear and ranks by + match strength; a read pages one message by character offset; a query is + matched literally for non-ASCII text. These are host reads: no provider request + is made. +- The second budget reminder is deleted rather than rephrased. Its sentence + ("unsummarized detail will not be available afterwards") is false while recall + exists. One reminder remains, at `clamp(hardLimit * 0.15, 8k, 32k)`, and the + hard boundary always warns. +- A checkpoint's opaque `details` gains a mechanical ledger (`buildLedger`): + files read and modified, commands, message and tool-call counts, the goal and + the unresolved items, projected as a bounded block after the summary. A + checkpoint written before the ledger existed projects the recall pointer alone, + and `tokensAfter` stays optional so an older record loads unchanged. +- See ADR 0300 and `03-runtime/02-agent-runtime.md` §5.1. + +## 2026-09-21 — Context pressure settings live on the model binding (D605) + +- `dynamicContext` defaults to `100% − max(100k, 15% × window) / window`, clamped + to 40–95 (a flat 60 when the window is unknown); `earlyCompaction` defaults to + 75 % of the hard limit after 120 s of idleness, silent; `sleepTime` is off with + a quota of 2 runs per hour. A binding without these fields keeps the previous + behavior, and the settings controls clamp against the same bounds the runtime + uses. +- One gate decides the pressure point for the outgoing request, the + hard-boundary check and the idle pass, so what narrowing saves is visible to + compaction instead of compaction firing while real room remained. Old tool + results are shortened to a head plus a recovery pointer, and a re-opened file's + result stays whole. +- The hard boundary itself stays fixed and unconfigurable; these settings move + when optional work happens, never when a request is refused. A successful idle + pass carries `idle`/`silent` on `compaction_end` and only the routine toast is + suppressed — the transcript row, the inspector and the record remain. See + ADR 0301, `03-runtime/16-tool-result-limits.md` §6a, `04-ux/06-settings-ia.md`. ## 2026-09-21 — A plugin crash reports its exit code without copying raw output (D607, issue #747) diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index 026d712c13..8ff19a03a8 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -417,6 +417,21 @@ Headroom 是 16,384 个代币储备底线的最大值,模型最大输出 委托(第 5f 节)对照它自己解析出的模型走同一条推导,并在它自己的回合边界上压缩, 但没有属于它自己的持久检查点链(ADR 0299)。 +**边界可回溯。** 完整转录与模型上下文是两个视图,压缩只作用于后者:任何检查点都不会改写、删除或隐藏已存的行。因此投影会说明 +模型仍能读到什么:`RECALL_POINTER` 指向两个 recall 工具,检查点的 `details.ledger`(由 `buildLedger` 生成)记录该边界覆盖的 +机械事实——读过与改过的文件、命令、消息与工具调用计数、目标与未解决项——以有界区块渲染在摘要之后。早于 ledger 的检查点只投影 +指针。摘要生成失败时,降级检查点用同一个助手描述自身覆盖范围,且不会为已完成的回合持久化空尾部(ADR 0300)。 + +**空闲预压缩。** 当模型绑定启用 `earlyCompaction`(ADR 0301)时,运行时在一次 run 结束后武装后台压缩:只有在 `delaySeconds` +内没有新活动才触发;任何新提示都会取消计时器或让本次让位,且该通道在花掉摘要请求之前会重新核对预算,因此窄化出足够空间即可 +取消它。它发出的 `compaction_end` 与内联路径相同,另带 `idle: true`;成功且设置为静默时带 `silent: true`,渲染层跳过例行 +toast。转录行、检查器与检查点记录照常写入;降级或失败以及硬边界始终告警。内联路径不可配置、行为不变。 + +**可配置压力闸门与工具结果降级。** 绑定上的 `dynamicContext` 决定在硬限制的哪个份额开始把旧工具结果缩成「头部 + 恢复指针」 +(`narrowToolResults`,`tool-result-tier.ts`);出站请求、硬边界判定与空闲压缩读同一个视图,所以窄化省下的量对压缩可见。 +会话重新打开过的文件的结果保持完整,单条工具限制(规格 16)不变,闸门关闭时不窄化。睡眠摘要(`sleepTime`)在摘要请求之前写入 +一条确定性的 `sleep` 转录行:不调用提供商,默认关闭。 + ## 5b.运营模式及规划状态 - 默认产品模式:**Agent** diff --git a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md index a088f1a6b6..40bf8cc8c7 100644 --- a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md @@ -334,6 +334,17 @@ ids 和非负 `tokensBefore`;它不会插入 message/search 行 工具值会清理主机保留键。每个插件每 60 秒最多 10 次单条导入、5 次批量导入和 20 次删除。P2/P3 方法不在协议 v11 中。 +- `session.recall` — 检索单个会话的完整转录:查询按词拆分,每个词都必须出现;命中按匹配词数与频次排序。非 ASCII 文本按字面 + 匹配(CJK 不做 ASCII 折叠)。返回带消息 id 的摘录,接受正数 `limit`;会话不存在为 `NOT_FOUND`。新增 RPC,协议版本不升。 +- `session.readMessage` — 以字符窗口读回单条消息正文:`offset` 为从 0 开始的字符偏移,`limit` 为字符页大小。返回该页、 + `hasMore` 与下一页偏移;偏移是字符而非字节,页边界不会切断码点。消息 id 未知时返回空页。这是工具结果的读回路径。 +- `search.query` — 按调用会话已存的项目绑定,在一个项目的多个会话中检索。绑定在其它项目的会话读作不存在,会话存在性不跨项目 + 泄漏。每个会话最多返回一条最佳命中。 +- `session.readProject` — `session.readMessage` 的项目级对应:同样的字符窗口读法,同样的项目绑定检查。 +- `session.appendSleep` — 追加一条 `sleep` 转录行(`SleepRecord`):对会话当前状态的确定性、无模型调用的摘要。布局扫描忽略 + 该行,它永不计数为消息,因此消息数、分页与转录投影不变。运行时在绑定启用睡眠摘要(ADR 0301)时写入;渲染层不显示为消息。 +- `compaction_end` 事件新增可选 `idle` / `silent`(成功且静默的空闲压缩),其 `mark` 新增可选 `tokensAfter`。 + ### Plan 和 Goal 状态和批准 两种合约类型共享这些方法;可选的 `kind` diff --git a/docs/zh-CN/spec/03-runtime/16-tool-result-limits.md b/docs/zh-CN/spec/03-runtime/16-tool-result-limits.md index ded53cfb35..401b241e0f 100644 --- a/docs/zh-CN/spec/03-runtime/16-tool-result-limits.md +++ b/docs/zh-CN/spec/03-runtime/16-tool-result-limits.md @@ -177,6 +177,16 @@ type GlobResult = { matches: string[]; count: number; truncated: boolean; notice 7. 超时和中止仅在完成过程后关闭两个输出流 树已被关闭;没有孤儿进程可以继续写入输出 +## 6a. 压力闸门与降级 + +单条限制约束一个结果,压力闸门约束结果集合。当模型绑定启用 `dynamicContext`(ADR 0301)且出站视图达到硬限制的阈值份额时, +旧工具结果被缩成「头部 + 指向如何读回全文的指针」——被丢弃的文本仍可通过 `recall` 读回,因此窄化不销毁数据。 + +- 闸门与出站请求、硬边界判定、空闲压缩测的是同一个视图; +- 会话重新打开过的文件的结果保持完整(工作集免疫),被排除的工具族与模块自身的下限同样豁免; +- 低于阈值或闸门关闭时,视图原样返回; +- 已存转录行永不被改写,被截断的结果仍带标记。 + ## 7. 验收标准 - [x] 超大 Bash 输出用标记截断并溢出更完整的副本 @@ -195,3 +205,4 @@ type GlobResult = { matches: string[]; count: number; truncated: boolean; notice - [ ] timeout/abort 停止完整的进程树并且不再发出后续块 - [ ] 超大并行结果批次压缩为有界标记尾部, 重新启动后仍然存在,并保持原始成绩单结果不变 +- [ ] 压力闸门只在阈值之上窄化旧工具结果,重新打开过的文件结果保持完整,全文仍可通过指针读回 diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index 3021924897..b794a52231 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -236,6 +236,10 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 无需确认的 Bash 命令。全局 AI 默认项卡必须描述两者 契约模式是意图边界,而不是严格的只读安全配置文件。 +- 同一个“高级”折叠区承载该模型的上下文压力设置:Dynamic context 开关加阈值滑杆(40–95 %,读数给出占硬限制的份额与约等 token + 数)、Early compaction 开关(阈值、空闲秒数、静默开关)、Sleep-time digest 开关(每小时配额)。每个控件写入绑定并在下一次 + 提示生效、无需重启;早于这些设置的绑定显示出厂默认值,取值按运行时同一边界钳制(ADR 0301)。 + ### 说明(`instructions` 选项卡) - 编辑每个 PI-Desktop Agent 会话使用的全局指令 Markdown。 - 显示解析的指令文件路径并通过主机支持保存 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 3015ed50ac..94ab00a596 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5337,6 +5337,10 @@ eleven-tool-round desktop paths are verified by | C / F / 品质 —— 上下文估算保持安全(校准) | E2E-CONTEXT-estimate-calibration-stays-safe | | C — 对话与流式(工具调用 id 唯一) | E2E-RUNTIME-unique-tool-call-ids-per-request | | 品质(工具调用 id 唯一) | E2E-RUNTIME-unique-tool-call-ids-per-request | +| C / F / 品质 —— 召回读回被压缩掉的上下文 | E2E-CONTEXT-recall-reads-a-compacted-away-message | +| C / 品质 —— 空闲压缩静默且会让位给提示 | E2E-CONTEXT-idle-compaction-is-silent-and-yields | +| C / 品质 —— 旧工具结果在压力下降级 | E2E-CONTEXT-tool-results-tier-under-pressure | +| B / 品质 —— 每模型上下文阈值 | E2E-SETTINGS-per-model-context-thresholds | | G — 插件宿主生命周期(崩溃上报) | E2E-PLUGIN-crash-report-names-the-exit-code | | 品质(崩溃上报) | E2E-PLUGIN-crash-report-names-the-exit-code | | F — 持久化(存储的模型绑定数组) | E2E-PROVIDER-stored-binding-array-reads-entry-by-entry | @@ -8447,6 +8451,43 @@ the latest destination. These assertions measure work counts, not device FPS. - **自动化:** `packages/agent-runtime/src/runtime.test.ts` 用真实的运行时覆盖两半:重复历史(丢弃 + 一行日志)与唯一历史 (同一对象、无日志)。 - **状态:** 单元测试覆盖;没有端到端驱动对重复转录发出真实提供商请求。 +### E2E-CONTEXT-recall-reads-a-compacted-away-message + +- **先决条件:** 用确定性提供商夹具把会话推过其上下文边界,使带摘要与 ledger 的检查点被安装;不使用真实凭据或付费 API。 +- **步骤:** 索要一个只存在于被移出模型上下文的消息中的 token。让模型用这些词调用 `recall`,再对返回的 id 用较小页大小调用 + `session.readMessage`;随后用 CJK 查询重复,并从同项目的第二个会话用 `recall_project`。 +- **预期:** 召回文本逐字返回;分页报告 `hasMore` 与下一页偏移;非 ASCII 查询不做 ASCII 折叠即可命中;绑定在其它项目的会话 + 读作不存在;投影摘要带 recall 指针与 ledger 区块;转录行不变。 +- **规格:** 03-runtime/02-agent-runtime §5.1、03-runtime/06-host-rpc-protocol §4 会话;ADR 0300。 +- **验收:** C(对话与流)、F(持久化)、品质。**状态:** 主机与单元测试已覆盖读路径;桌面 E2E 待确定性边界夹具。 + +### E2E-CONTEXT-idle-compaction-is-silent-and-yields + +- **先决条件:** 绑定启用 `earlyCompaction` 且 `delaySeconds` 较短;确定性摘要夹具;一次已结束且高于早期阈值的 run。 +- **步骤:** 让会话空闲超过延迟。重复一次,但在延迟窗口内发送提示。再用 `silent: false` 跑一次,并用会失败的摘要夹具跑一次。 +- **预期:** 第一次以 `compaction_end.idle` 与 `.silent` 压缩,不出现例行 toast,转录行与上下文检查器仍记录检查点;窗口内的 + 提示取消本次压缩且不发出摘要请求;`silent: false` 恢复 toast;降级与硬边界始终告警。 +- **规格:** 03-runtime/02-agent-runtime §5.1;ADR 0301。**验收:** C(对话与流)、品质。**状态:** 单元测试覆盖计时器与 + 预算接口;桌面 E2E 待补。 + +### E2E-CONTEXT-tool-results-tier-under-pressure + +- **先决条件:** 会话尺寸使出站视图越过 `dynamicContext` 闸门,其中包含一条稍后会被重新打开的文件的结果。 +- **步骤:** 用旧工具结果填满上下文,再发一条会重新打开其中一个文件的提示;分别在闸门开与关时比较出站请求。 +- **预期:** 闸门开时旧结果缩成头部 + 恢复指针,重新打开的文件结果保持完整,全文仍可经 recall 读回;闸门关时不窄化;单条工具 + 限制与已存行不变。 +- **规格:** 03-runtime/16-tool-result-limits §6a;ADR 0301。**验收:** C(对话与流)、品质。**状态:** 单元测试覆盖 + (`tool-result-tier.test.ts`);桌面 E2E 待补。 + +### E2E-SETTINGS-per-model-context-thresholds + +- **先决条件:** 模型目的地至少有一个已配置绑定;隔离的桌面配置。 +- **步骤:** 打开该绑定的“高级”。把动态上下文阈值调到边界以下与以上;切换早期压缩并把延迟改到接受范围之外;切换睡眠摘要并把 + 配额推到上限之上;保存并重新打开面板。随后在不重启的情况下发送一条提示。 +- **预期:** 取值按共享边界钳制;读数给出占硬限制的份额与约等 token 数;设置随绑定持久化并在下一次提示生效;未设置过的绑定 + 显示默认值。 +- **规格:** 04-ux/06-settings-ia §2 模型配置;ADR 0301。**验收:** B(模型配置)、品质。**状态:** 桌面契约测试覆盖面板 + 默认值;交互式 E2E 待补。 ### E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index cb3a1f9695..46a4e579e8 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4619,6 +4619,27 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays. - 一旦发生丢弃,会在 `agent` 日志通道上报告一次,带上会话与 id,使下一次同类报障能指向写入方而不只是提供商那句话。 见 `03-runtime/02-agent-runtime §5`。 - 守卫刻意放在请求边界而不是历史重建处:这样也能覆盖**会话运行期间**产生的重复,而重建期的过滤看不到它。 +## 2026-09-21 —— 压缩边界可回溯,提示条只剩一层(D604) + +- `recall` 与 `recall_project` 进入**核心工具集**:通过 `session.recall`、`session.readMessage`、`search.query` 与 + `session.readProject` 读回已存转录,被检查点移出模型上下文的消息、或被窄化结果替换为指针的内容都能逐字读回。检索要求每个 + 查询词都出现并按匹配强度排序;读取按字符分页;非 ASCII 文本按字面匹配。这些都是主机侧读取,不产生提供商请求。 +- 第二层预算提示条被删除而非改写:在 recall 存在的前提下「未摘要的细节之后将不可用」是假话。保留的一层在 + `clamp(hardLimit * 0.15, 8k, 32k)` 提醒写出持久状态,硬边界始终告警。 +- 检查点的 `details` 新增机械 ledger(`buildLedger`):读过与改过的文件、命令、消息与工具调用计数、目标与未解决项,作为有界 + 区块投影在摘要之后;早于 ledger 的检查点只投影 recall 指针,`tokensAfter` 保持可选、旧记录照常加载。见 ADR 0300 与 + `03-runtime/02-agent-runtime.md` §5.1。 + +## 2026-09-21 —— 上下文压力设置挂在模型绑定上(D605) + +- `dynamicContext` 默认 `100% − max(100k, 15% × window) / window`,钳制 40–95(窗口未知时扁平 60);`earlyCompaction` + 默认硬限制的 75 %、空闲 120 s 后执行且静默;`sleepTime` 默认关闭、每小时配额 2 次。缺少这些字段的绑定行为不变,控件与运行时 + 按同一组边界钳制。 +- 唯一闸门同时决定出站请求、硬边界判定与空闲压缩的压力点,因此窄化省下的量对压缩可见;旧工具结果缩成头部 + 恢复指针,重新打开 + 过的文件保持完整。 +- 硬边界本身固定且不可配置;这些设置改变的是“可选工作何时发生”,而不是“请求何时被拒绝”。成功且静默的空闲压缩在 + `compaction_end` 上带 `idle`/`silent`,只跳过例行 toast——转录行、检查器与记录照常。见 ADR 0301、 + `03-runtime/16-tool-result-limits.md` §6a、`04-ux/06-settings-ia.md`。 ## 2026-09-21 —— 插件崩溃上报带上退出码但不复制原始输出(D607,issue #747) diff --git a/packages/agent-runtime/src/compaction-trajectory.test.ts b/packages/agent-runtime/src/compaction-trajectory.test.ts new file mode 100644 index 0000000000..014980121a --- /dev/null +++ b/packages/agent-runtime/src/compaction-trajectory.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; +import { + TRAJECTORY_MAX_GOAL_CHARS, + TRAJECTORY_MAX_NEXT_STEPS, + TRAJECTORY_MAX_OPEN_ITEMS, + buildTrajectorySummary, + extractGoal, + extractNextSteps, + extractOpenItems, + type TrajectoryMessage, +} from "./compaction-trajectory.js"; + +/** + * The property that makes a trajectory checkpoint a real summary rather than a + * note: the same minimum length and section headings the runtime's own summary + * check enforces, so the next summarization request can carry it forward like + * any other summary instead of being a special case. + */ +const REQUIRED_SECTIONS = ["## Goal", "## Progress", "## Next Steps"] as const; + +function carryableAsSummary(text: string): boolean { + if (text.trim().length < 200) return false; + return REQUIRED_SECTIONS.every((section) => + new RegExp(`^${section}\\s*$`, "m").test(text), + ); +} + +function user(text: string): TrajectoryMessage { + return { role: "user", content: text }; +} + +function assistant( + text: string, + calls: Array<{ id?: string; name: string; arguments?: unknown }> = [], +): TrajectoryMessage { + return { + role: "assistant", + content: [ + ...(text ? [{ type: "text", text }] : []), + ...calls.map((call) => ({ + type: "toolCall", + id: call.id, + name: call.name, + arguments: call.arguments ?? {}, + })), + ], + }; +} + +function failure( + toolName: string, + text: string, + toolCallId?: string, +): TrajectoryMessage { + return { + role: "toolResult", + toolName, + isError: true, + toolCallId, + content: [{ type: "text", text }], + }; +} + +describe("extractGoal", () => { + it("takes the last user request verbatim", () => { + expect( + extractGoal([user("first ask"), user("second ask")]), + ).toBe("second ask"); + }); + + it("ignores an empty or non-text user message", () => { + expect(extractGoal([user("real ask"), user(" ")])).toBe("real ask"); + expect(extractGoal([{ role: "user" }])).toBeUndefined(); + }); + + it("bounds a goal that would dominate the checkpoint", () => { + const goal = extractGoal([user("x".repeat(TRAJECTORY_MAX_GOAL_CHARS + 50))]); + expect(goal?.length).toBe(TRAJECTORY_MAX_GOAL_CHARS + 1); + expect(goal?.endsWith("…")).toBe(true); + }); + + it("returns undefined when the range holds no user request", () => { + expect(extractGoal([assistant("done")])).toBeUndefined(); + }); +}); + +describe("extractOpenItems", () => { + it("names a failure nothing repaired", () => { + expect( + extractOpenItems([ + assistant("", [{ id: "call-1", name: "Bash", arguments: { command: "x" } }]), + failure("Bash", "command failed: exit 1", "call-1"), + ]), + ).toEqual(["Bash: command failed: exit 1"]); + }); + + it("drops a failure a later edit to the same file repaired", () => { + expect( + extractOpenItems([ + assistant("", [{ id: "read-1", name: "Read", arguments: { file_path: "a.ts" } }]), + failure("Read", "file not found", "read-1"), + assistant("", [{ id: "write-1", name: "Write", arguments: { file_path: "a.ts" } }]), + // The repair has to have landed: a Write that never returned, or one + // that failed, is not evidence that the file became readable. + { + role: "toolResult", + toolName: "Write", + isError: false, + toolCallId: "write-1", + content: [{ type: "text", text: "written" }], + }, + ]), + ).toEqual([]); + }); + + it("keeps a failure whose repair came before it", () => { + // The edit belongs to earlier work; the failure happened afterwards and is + // still unexplained, which is exactly what a next window needs to know. + expect( + extractOpenItems([ + assistant("", [{ id: "write-1", name: "Write", arguments: { file_path: "a.ts" } }]), + assistant("", [{ id: "read-1", name: "Read", arguments: { file_path: "a.ts" } }]), + failure("Read", "file not found", "read-1"), + ]), + ).toEqual(["Read: file not found"]); + }); + + it("keeps a failure it cannot attribute to a path", () => { + expect( + extractOpenItems([failure("Bash", "npm test failed", "unknown-call")]), + ).toEqual(["Bash: npm test failed"]); + }); + + it("takes only the first line and bounds the list", () => { + const items = extractOpenItems([ + failure("Bash", "first line\nsecond line"), + ...Array.from({ length: TRAJECTORY_MAX_OPEN_ITEMS + 3 }, (_, index) => + failure("Bash", `failure ${index}`), + ), + ]); + expect(items[0]).toBe("Bash: first line"); + expect(items).toHaveLength(TRAJECTORY_MAX_OPEN_ITEMS); + }); + + it("does not repeat an identical failure", () => { + expect( + extractOpenItems([failure("Bash", "same"), failure("Bash", "same")]), + ).toEqual(["Bash: same"]); + }); + + it("ignores a result that succeeded", () => { + expect( + extractOpenItems([ + { role: "toolResult", toolName: "Bash", isError: false, content: "ok" }, + ]), + ).toEqual([]); + }); + + it("matches a result to the nearest preceding call, never to a later one", () => { + // OpenAI-compatible local servers emit ids like `1`, `2` per response, so a + // whole range contains repeats. The call a failure belongs to is the one + // that precedes it; a later call reusing the id says nothing about it. + expect( + extractOpenItems([ + assistant("", [{ id: "1", name: "Read", arguments: { file_path: "a.ts" } }]), + failure("Read", "file not found", "1"), + assistant("", [{ id: "1", name: "Write", arguments: { file_path: "a.ts" } }]), + { + role: "toolResult", + toolName: "Write", + isError: false, + toolCallId: "1", + content: [{ type: "text", text: "written" }], + }, + ]), + ).toEqual(["Read: file not found"]); + }); + + it("does not treat a failed edit as a repair", () => { + expect( + extractOpenItems([ + assistant("", [{ id: "r1", name: "Read", arguments: { file_path: "a.ts" } }]), + failure("Read", "file not found", "r1"), + assistant("", [{ id: "w1", name: "Write", arguments: { file_path: "a.ts" } }]), + failure("Write", "the write itself failed", "w1"), + ]), + ).toEqual(["Read: file not found", "Write: the write itself failed"]); + }); + + it("does not let a call that never returned hide a failure", () => { + expect( + extractOpenItems([ + assistant("", [{ id: "r1", name: "Read", arguments: { file_path: "a.ts" } }]), + failure("Read", "file not found", "r1"), + // Unverifiable, so it cannot count as the repair that resolved the + // failure above: an unresolved call is not evidence of anything. + assistant("", [{ id: "w1", name: "Write", arguments: { file_path: "a.ts" } }]), + ]), + ).toEqual(["Read: file not found"]); + }); +}); + +describe("extractNextSteps", () => { + it("reads unchecked checkboxes and literal TODOs", () => { + expect( + extractNextSteps([ + assistant("- [ ] wire the new layer\n- [x] done\nTODO: run the suite"), + ]), + ).toEqual(["wire the new layer", "run the suite"]); + }); + + it("does not turn prose into a task list", () => { + expect( + extractNextSteps([ + assistant("## Next Steps\n\nEverything below was finished already."), + ]), + ).toEqual([]); + }); + + it("reads the last assistant message that has text", () => { + expect( + extractNextSteps([assistant("TODO: old"), assistant(""), assistant("TODO: new")]), + ).toEqual(["new"]); + }); + + it("bounds the list", () => { + const steps = extractNextSteps([ + assistant( + Array.from({ length: TRAJECTORY_MAX_NEXT_STEPS + 4 }, (_, i) => `- [ ] step ${i}`).join("\n"), + ), + ]); + expect(steps).toHaveLength(TRAJECTORY_MAX_NEXT_STEPS); + }); +}); + +describe("buildTrajectorySummary", () => { + const range: TrajectoryMessage[] = [ + user("older ask"), + assistant("", [{ id: "call-1", name: "Bash", arguments: { command: "ls" } }]), + failure("Bash", "cannot ls here", "call-1"), + user("the ask that matters"), + assistant("TODO: finish the record"), + ]; + + it("carries the goal, the unresolved failures and the next step", () => { + const summary = buildTrajectorySummary({ messages: range }); + expect(summary.sections).toContain("## Goal"); + expect(summary.sections).toContain("the ask that matters"); + expect(summary.sections).toContain("## Blocked"); + expect(summary.sections).toContain("- Bash: cannot ls here"); + expect(summary.sections).toContain("## Next Steps"); + expect(summary.sections).toContain("- finish the record"); + expect(summary.stats).toEqual({ + messages: 5, + toolCalls: 1, + failedToolCalls: 1, + }); + expect(summary.goal).toBe("the ask that matters"); + }); + + it("carries as a real summary, not a note", () => { + // The headings and the minimum length are the ones the runtime's summary + // check enforces, so a trajectory checkpoint can be carried into the next + // summarization request like any other summary instead of being a special + // case. + const summary = buildTrajectorySummary({ messages: range }); + expect(carryableAsSummary(summary.sections)).toBe(true); + }); + + it("says so when a section has nothing to carry", () => { + const summary = buildTrajectorySummary({ messages: [assistant("done")] }); + expect(summary.sections).toContain("(no user request was recorded in this range)"); + expect(summary.sections).toContain("## Blocked\n(none recorded)"); + expect(summary.sections).toContain("## Next Steps\n(no open next step was recorded)"); + expect(summary.goal).toBeUndefined(); + }); + + it("keeps the carried summary ahead of the sections, bounded", () => { + const summary = buildTrajectorySummary({ + messages: range, + previousSummary: " The earlier task summary. ", + }); + expect(summary.carried).toBe("The earlier task summary."); + expect(summary.summary.startsWith("The earlier task summary.")).toBe(true); + expect(summary.sections).not.toContain("The earlier task summary."); + }); + + it("bounds a carried summary that has grown past its budget", () => { + const summary = buildTrajectorySummary({ + messages: range, + previousSummary: "x".repeat(10_000), + maxPreviousChars: 64, + }); + expect(summary.carried?.length).toBe(65); + }); + + it("treats a blank carried summary as none", () => { + expect(buildTrajectorySummary({ messages: range, previousSummary: " " }).carried) + .toBeUndefined(); + }); + + it("never throws on messages it does not understand", () => { + const summary = buildTrajectorySummary({ + messages: [ + { role: "custom" }, + { role: "assistant", content: "not a block list" }, + { role: "toolResult", isError: true }, + ], + }); + expect(summary.sections).toContain("## Goal"); + expect(summary.openItems).toEqual(["tool"]); + }); +}); diff --git a/packages/agent-runtime/src/compaction-trajectory.ts b/packages/agent-runtime/src/compaction-trajectory.ts new file mode 100644 index 0000000000..59981d5edc --- /dev/null +++ b/packages/agent-runtime/src/compaction-trajectory.ts @@ -0,0 +1,379 @@ +/** + * Deterministic compaction recovery. + * + * Why this exists + * --------------- + * When the summary request fails, the runtime falls back to a carried-forward + * summary plus a recovery notice: real history is dropped and the next window is + * told nothing about what the work was. The failure this module was written for + * is the evidence: 750k tokens of work replaced by a notice plus a + * machine-generated ledger of file names, with no statement of the goal, of what + * went wrong, or of what was left to do. + * + * The layer this adds is model-free, so it cannot fail on a provider and it + * costs no request: the range is described *mechanically* — the last request + * that was being worked on, verbatim; how much work the range holds and how much + * of it failed; which failures were never resolved; and the open items the last + * assistant message stated. It slots between a failed model summary and the + * retained-tail notice. + * + * What this file is not + * --------------------- + * It does not talk to a model, read the filesystem, or know about checkpoints. + * Everything here is a pure function of the messages it is given, which is what + * lets the whole degradation ladder be tested without a provider stub. The + * narrative a model would have written is the runtime's business; this is the + * part that must be true even when no model answers. + * + * Duplication with the session ledger is deliberate but bounded: files, + * commands and range totals are the ledger's job, so a trajectory summary never + * restates them. What it adds is exactly what the ledger lacks — the goal, the + * unresolved failures, and the next step. + */ + +/** The verbatim goal is the one thing a next window cannot reconstruct. */ +export const TRAJECTORY_MAX_GOAL_CHARS = 1_500; + +/** Unresolved failures worth naming; past this they stop being actionable. */ +export const TRAJECTORY_MAX_OPEN_ITEMS = 8; +export const TRAJECTORY_OPEN_ITEM_CHARS = 200; + +/** Open items the last assistant message stated. */ +export const TRAJECTORY_MAX_NEXT_STEPS = 6; + +/** Carried-forward summary budget, before the range's own description. */ +export const TRAJECTORY_MAX_PREVIOUS_CHARS = 4_000; + +/** + * The message fields this module reads. Structural rather than pi's message + * union so a test can pass a literal, and so a future message role this module + * does not understand simply contributes nothing instead of failing a cast. + */ +export type TrajectoryMessage = { + role: string; + content?: unknown; + isError?: boolean; + toolName?: string; + toolCallId?: string; +}; + +export type TrajectoryStats = { + messages: number; + toolCalls: number; + failedToolCalls: number; +}; + +export type TrajectorySummary = { + /** + * The real summary a chained compaction was carrying, bounded. The runtime + * places it ahead of its recovery marker, so `stripCompactionFallbackNotice` + * recovers exactly this text for the next attempt — the sections below + * describe *this* range and must not be carried as if they were history. + */ + carried: string | undefined; + /** The mechanical description of this range; the runtime places it after the marker. */ + sections: string; + /** `carried` + `sections`, for a reader that has no marker to place. */ + summary: string; + stats: TrajectoryStats; + /** Named for the checkpoint's diagnostics; undefined when nothing recorded it. */ + goal: string | undefined; + openItems: string[]; + nextSteps: string[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Text of every text block, in order. A string content is its own text. */ +function messageText(message: TrajectoryMessage): string { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (isRecord(block) && block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + } + } + return parts.join("\n"); +} + +function firstLine(text: string, maxChars: number): string { + const line = text + .split(/\r?\n/) + .map((candidate) => candidate.trim()) + .find((candidate) => candidate.length > 0); + if (!line) return ""; + return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line; +} + +function pathArgument(args: Record): string | undefined { + for (const key of ["path", "file_path", "filePath"]) { + const value = args[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +/** Tool-call blocks of one assistant message, with the arguments we can read. */ +function toolCallsOf( + message: TrajectoryMessage, +): Array<{ id: string | undefined; name: string; path: string | undefined }> { + const content = message.content; + if (!Array.isArray(content)) return []; + const calls: Array<{ + id: string | undefined; + name: string; + path: string | undefined; + }> = []; + for (const block of content) { + if (!isRecord(block) || block.type !== "toolCall") continue; + const name = typeof block.name === "string" ? block.name : ""; + if (!name) continue; + const args = isRecord(block.arguments) ? block.arguments : {}; + calls.push({ + id: typeof block.id === "string" ? block.id : undefined, + name, + path: pathArgument(args), + }); + } + return calls; +} + +/** + * The last request the range was working on, verbatim. + * + * Verbatim matters: Roo Code's prompt asks a summarizer for the same thing + * because a paraphrase is what lets a next window redo work whose wording it + * cannot tell apart from a different request. + */ +export function extractGoal( + messages: readonly TrajectoryMessage[], +): string | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role !== "user") continue; + const text = messageText(message).trim(); + if (text.length === 0) continue; + return text.length > TRAJECTORY_MAX_GOAL_CHARS + ? `${text.slice(0, TRAJECTORY_MAX_GOAL_CHARS)}…` + : text; + } + return undefined; +} + +/** + * Failures in the range that nothing later repaired. + * + * "Unresolved" is the useful half: a test run that failed and was fixed by the + * next edit is not something a next window should act on, and listing it would + * teach the reader to skim this section. A failure whose replacement edit came + * later in the same range is therefore dropped; everything else — including a + * failure this module cannot attribute to a path — is kept, because an + * unexplained failure is exactly what a next window needs to know about. + * + * Two attribution rules keep that from hiding a real failure: + * + * - A call id is **not** unique across a range — OpenAI-compatible local + * servers emit `1`, `2`, … per response — so a result is matched to the + * nearest *preceding* call with that id, never to a later one. + * - A repair counts only when its own result came back without an error. A + * `Write` that failed repaired nothing, and a call that never returned is + * not evidence either, so neither suppresses an item. + */ +export function extractOpenItems( + messages: readonly TrajectoryMessage[], +): string[] { + const calls = new Map< + string, + Array<{ index: number; name: string; path: string | undefined }> + >(); + const resultIds = new Set(); + const failedIds = new Set(); + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + if (!message) continue; + if (message.role === "assistant") { + for (const call of toolCallsOf(message)) { + if (!call.id) continue; + const list = calls.get(call.id) ?? []; + list.push({ index, name: call.name, path: call.path }); + calls.set(call.id, list); + } + continue; + } + if (message.role !== "toolResult") continue; + if (typeof message.toolCallId !== "string") continue; + resultIds.add(message.toolCallId); + if (message.isError === true) failedIds.add(message.toolCallId); + } + + /** The call a result belongs to: the last one with that id before it. */ + const callBefore = (id: string, index: number) => { + const list = calls.get(id); + if (!list) return undefined; + for (let position = list.length - 1; position >= 0; position--) { + const call = list[position]; + if (call && call.index < index) return call; + } + return undefined; + }; + const repaired = (id: string | undefined, index: number): boolean => { + if (!id || !resultIds.has(id) || failedIds.has(id)) return false; + const call = callBefore(id, index); + return call?.index !== undefined; + }; + + // Paths an edit actually landed on, so a failure for that path is resolved. + const lastRepair = new Map(); + for (const [id, list] of calls) { + if (!repaired(id, Number.POSITIVE_INFINITY)) continue; + const call = list[list.length - 1]; + if (!call?.path) continue; + if (call.name !== "Write" && call.name !== "Edit" && call.name !== "MultiEdit") { + continue; + } + lastRepair.set(call.path, call.index); + } + + const items: string[] = []; + const seen = new Set(); + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + if (!message || message.role !== "toolResult" || message.isError !== true) { + continue; + } + const call = + typeof message.toolCallId === "string" + ? callBefore(message.toolCallId, index) + : undefined; + if (call?.path) { + const repair = lastRepair.get(call.path); + if (repair !== undefined && repair > index) continue; + } + const name = + call?.name || + (typeof message.toolName === "string" ? message.toolName : "tool"); + const detail = firstLine(messageText(message), TRAJECTORY_OPEN_ITEM_CHARS); + const item = detail ? `${name}: ${detail}` : name; + if (seen.has(item)) continue; + seen.add(item); + items.push(item); + if (items.length >= TRAJECTORY_MAX_OPEN_ITEMS) break; + } + return items; +} + + +/** + * Open items the last assistant message stated. + * + * Only markers that cannot appear by accident are read — an unchecked checkbox + * and a literal `TODO`. A looser rule (any line under a "next steps" heading) + * would turn prose into a task list the model never committed to, and the + * sections above already carry the goal verbatim. + */ +export function extractNextSteps( + messages: readonly TrajectoryMessage[], +): string[] { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role !== "assistant") continue; + const text = messageText(message); + if (text.trim().length === 0) continue; + const steps: string[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const checkbox = rawLine.match(/^\s*[-*]\s*\[\s*\]\s*(.+)$/); + const todo = rawLine.match(/^\s*(?:[-*]\s*)?TODO\b[:\s]*(.*)$/i); + const value = (checkbox?.[1] ?? todo?.[1] ?? "").trim(); + if (!value) continue; + steps.push( + value.length > TRAJECTORY_OPEN_ITEM_CHARS + ? `${value.slice(0, TRAJECTORY_OPEN_ITEM_CHARS)}…` + : value, + ); + if (steps.length >= TRAJECTORY_MAX_NEXT_STEPS) break; + } + return steps; + } + return []; +} + +const TRAJECTORY_GOAL_EMPTY = "(no user request was recorded in this range)"; +const TRAJECTORY_BLOCKED_EMPTY = "(none recorded)"; +const TRAJECTORY_NEXT_EMPTY = "(no open next step was recorded)"; + +/** + * The mechanical replacement for a model summary. + * + * The section headings are the ones the A check enforces, on purpose: a + * trajectory checkpoint is then structurally a summary, so the next + * summarization request can carry it forward and every reader — human or model — + * sees the same shape regardless of which layer produced it. + * + * `previousSummary` is placed ahead of the caller's marker, exactly like the + * retained-tail fallback does, so `stripCompactionFallbackNotice` can recover + * the real carried-forward history on a chained compaction and this module's + * description of the *previous* range is dropped rather than cemented. + */ +export function buildTrajectorySummary(input: { + messages: readonly TrajectoryMessage[]; + previousSummary?: string; + /** Bound for the carried-forward text; the sections bound themselves. */ + maxPreviousChars?: number; +}): TrajectorySummary { + const messages = input.messages; + const stats: TrajectoryStats = { + messages: messages.length, + toolCalls: 0, + failedToolCalls: 0, + }; + for (const message of messages) { + if (message.role === "assistant") { + stats.toolCalls += toolCallsOf(message).length; + } else if (message.role === "toolResult" && message.isError === true) { + stats.failedToolCalls += 1; + } + } + + const goal = extractGoal(messages); + const openItems = extractOpenItems(messages); + const nextSteps = extractNextSteps(messages); + const previousBudget = input.maxPreviousChars ?? TRAJECTORY_MAX_PREVIOUS_CHARS; + const previous = + typeof input.previousSummary === "string" && input.previousSummary.trim().length > 0 + ? input.previousSummary.trim().length > previousBudget + ? `${input.previousSummary.trim().slice(0, previousBudget)}…` + : input.previousSummary.trim() + : undefined; + + const sections = [ + "## Goal", + goal ?? TRAJECTORY_GOAL_EMPTY, + "", + "## Progress", + `${stats.messages} messages and ${stats.toolCalls} tool calls were summarized without a model pass; ${stats.failedToolCalls} tool call(s) in the range failed. Files, commands and totals for this range are listed in the session ledger below.`, + "", + "## Blocked", + openItems.length > 0 + ? openItems.map((item) => `- ${item}`).join("\n") + : TRAJECTORY_BLOCKED_EMPTY, + "", + "## Next Steps", + nextSteps.length > 0 + ? nextSteps.map((item) => `- ${item}`).join("\n") + : TRAJECTORY_NEXT_EMPTY, + ].join("\n"); + + return { + carried: previous, + sections, + summary: previous ? `${previous}\n\n${sections}` : sections, + stats, + goal, + openItems, + nextSteps, + }; +} diff --git a/packages/agent-runtime/src/provider-binding.ts b/packages/agent-runtime/src/provider-binding.ts index cc7b955b67..32c6b6e435 100644 --- a/packages/agent-runtime/src/provider-binding.ts +++ b/packages/agent-runtime/src/provider-binding.ts @@ -54,7 +54,33 @@ export type RuntimeProviderConfig = { supportsReasoning: boolean; supportedThinkingLevels: ThinkingLevel[]; /** Complete model metadata resolved from models.dev by Electron main. */ + /** Complete model metadata resolved from models.dev by Electron main. */ modelConfig?: ModelConfig; + /** + * Dynamic context gate from the model binding (D447): the share of the hard + * limit at which old tool results start being narrowed. Absent keeps the + * runtime default; the desktop sends it per launch, so a settings change + * applies to the next prompt. + */ + dynamicContext?: { enabled: boolean; thresholdPercent: number }; + /** + * Early background compaction for this model + * (`ModelBinding.earlyCompaction`): the share of the hard limit at which a + * settled, idle session compacts itself in the background, how long it waits, + * and whether a successful pass stays out of the toast channel. Absent keeps + * the runtime defaults; the inline hard-limit path ignores all of it. + */ + earlyCompaction?: { + enabled: boolean; + thresholdPercent: number; + delaySeconds: number; + silent: boolean; + }; + /** + * Sleep-time digest for this model (ADR 0301). Absent keeps it off; the + * runtime clamps the quota through the shared helper the settings pane uses. + */ + sleepTime?: { enabled: boolean; maxRunsPerHour: number }; /** * Optional outbound HTTP headers. Empty/absent keeps adapter defaults. * Injected last via a fetch wrapper so Codex/Anthropic cannot overwrite them. diff --git a/packages/agent-runtime/src/recall-tools.test.ts b/packages/agent-runtime/src/recall-tools.test.ts new file mode 100644 index 0000000000..efeb54be4b --- /dev/null +++ b/packages/agent-runtime/src/recall-tools.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { + buildRecallProjectTool, + buildRecallTool, + RECALL_MAX_MATCHES, + RECALL_MAX_OUTPUT_CHARS, + recallAnswerText, + type RecallHost, +} from "./recall-tools.js"; + +/** + * A host adapter that records every call, so a test can assert what the tool + * asked for — not only what it printed. `replies` maps a method to its answer. + */ +function fakeHost( + replies: Record = {}, +): RecallHost & { calls: Array<{ method: string; params: Record }> } { + const calls: Array<{ method: string; params: Record }> = []; + return { + sessionId: "session-1", + calls, + call: async (method, params) => { + calls.push({ method, params }); + if (method in replies) { + const value = replies[method]; + if (value instanceof Error) throw value; + return value; + } + throw new Error(`unexpected host call: ${method}`); + }, + }; +} + +/** Run one tool and return its single text payload. */ +async function runText( + tool: { execute?: unknown }, + params: Record, +): Promise { + const execute = (tool as { execute: (id: string, p: unknown) => Promise }) + .execute; + const result = (await execute("call-1", params)) as { + content: Array<{ text: string }>; + }; + return result.content.map((block) => block.text).join(""); +} + +describe("recall tool", () => { + it("searches this session and says what it matched", async () => { + const host = fakeHost({ + "session.recall": { + hits: [ + { id: "m3", role: "user", createdAt: "2026-09-08T00:00:00Z", index: 3, snippet: "retry budget" }, + ], + totalMessages: 12, + }, + }); + const text = await runText(buildRecallTool(host), { query: "retry budget" }); + + expect(host.calls).toEqual([ + { method: "session.recall", params: { sessionId: "session-1", query: "retry budget", limit: 10 } }, + ]); + expect(text).toContain("1 of 12 messages match"); + expect(text).toContain("(id: m3)"); + expect(text).toContain("retry budget"); + }); + + it("teaches the query rule instead of answering a paraphrase with nothing", async () => { + const host = fakeHost({ + "session.recall": { hits: [], totalMessages: 40 }, + }); + const text = await runText(buildRecallTool(host), { + query: "how did we fix the retry problem", + }); + + expect(text).toContain("No message in this session contains every word"); + expect(text).toContain("as they were written"); + expect(text).toContain("unspaced Chinese"); + }); + + it("asks for a query or an id rather than calling the host blind", async () => { + const host = fakeHost(); + const text = await runText(buildRecallTool(host), {}); + expect(text).toContain("Provide either `query`"); + expect(host.calls).toHaveLength(0); + }); + + it("reads one message by id and states where the next page starts", async () => { + const host = fakeHost({ + "session.readMessage": { + messageId: "m9", + role: "tool", + createdAt: "2026-09-08T00:00:01Z", + index: 9, + totalChars: 10_000, + offset: 0, + text: "head of the tool result", + hasMore: true, + nextOffset: 8_000, + }, + }); + const text = await runText(buildRecallTool(host), { messageId: "m9" }); + + expect(host.calls[0]?.method).toBe("session.readMessage"); + expect(host.calls[0]?.params).toMatchObject({ sessionId: "session-1", messageId: "m9", offset: 0 }); + expect(text).toContain("characters 0–8000 of 10000"); + expect(text).toContain('call recall again with messageId "m9" and offset 8000'); + }); + + it("reports a host failure as text instead of throwing at the model", async () => { + const host = fakeHost({ "session.recall": new Error("host offline") }); + const text = await runText(buildRecallTool(host), { query: "anything" }); + expect(text).toContain("recall failed: host offline"); + }); + + it("caps the answer and says the cap was reached", () => { + const hits = Array.from({ length: RECALL_MAX_MATCHES }, (_value, index) => ({ + id: `m${index}`, + role: "user", + createdAt: "2026-09-08T00:00:00Z", + index, + snippet: "x".repeat(4_000), + })); + const text = recallAnswerText({ hits, totalMessages: 99 }, "x"); + expect(text.length).toBeLessThanOrEqual(RECALL_MAX_OUTPUT_CHARS + 200); + expect(text).toContain("output truncated"); + }); +}); + +describe("recall_project tool", () => { + it("refuses when the session has no project instead of guessing a scope", async () => { + const host = fakeHost(); + const text = await runText(buildRecallProjectTool(host, undefined), { query: "anything" }); + expect(text).toContain("no project path"); + expect(host.calls).toHaveLength(0); + }); + + it("scopes a search to this project and reports one row per session", async () => { + const host = fakeHost({ + "search.query": { + hits: [ + { + sessionId: "s-2", + sessionTitle: "earlier session", + messageId: "m7", + role: "user", + snippet: "spinner plan", + createdAt: "2026-09-07T00:00:00Z", + }, + ], + }, + }); + const text = await runText(buildRecallProjectTool(host, "C:/work/alpha"), { + query: "spinner", + }); + + expect(host.calls[0]).toEqual({ + method: "search.query", + params: { query: "spinner", limit: 10, projectPath: "C:/work/alpha" }, + }); + expect(text).toContain("earlier session (sessionId: s-2)"); + expect(text).toContain("1 session(s) of this project match"); + expect(text).toContain("Read one back with recall_project and its sessionId"); + }); + + it("pages a project session backwards with beforeSeq", async () => { + const host = fakeHost({ + "session.readProject": { + sessionId: "s-2", + sessionTitle: "earlier session", + total: 40, + hasMore: true, + messages: [ + { id: "m5", seq: 5, role: "user", text: "older", createdAt: "2026-09-07T00:00:00Z" }, + { id: "m6", seq: 6, role: "tool", text: "row", createdAt: "2026-09-07T00:00:01Z", toolName: "Read", truncated: true }, + ], + }, + }); + const text = await runText(buildRecallProjectTool(host, "C:/work/alpha"), { + sessionId: "s-2", + }); + + expect(host.calls[0]?.method).toBe("session.readProject"); + expect(text).toContain("[seq 6] tool"); + expect(text).toContain("[truncated row; read it by id]"); + expect(text).toContain("beforeSeq=5"); + }); + + it("reads one project message by id through the project scope", async () => { + const host = fakeHost({ + "session.readMessage": { + messageId: "m6", + role: "tool", + createdAt: "2026-09-07T00:00:01Z", + index: 6, + totalChars: 9_000, + offset: 0, + text: "the whole tool result", + hasMore: false, + nextOffset: 9_000, + }, + }); + const text = await runText(buildRecallProjectTool(host, "C:/work/alpha"), { + sessionId: "s-2", + messageId: "m6", + }); + + expect(host.calls[0]?.params).toMatchObject({ + sessionId: "s-2", + messageId: "m6", + projectPath: "C:/work/alpha", + }); + expect(text).toContain("the whole tool result"); + expect(text).not.toContain("more of this message remains"); + }); +}); diff --git a/packages/agent-runtime/src/recall-tools.ts b/packages/agent-runtime/src/recall-tools.ts new file mode 100644 index 0000000000..6823e3a1ff --- /dev/null +++ b/packages/agent-runtime/src/recall-tools.ts @@ -0,0 +1,456 @@ +/** + * The recall tools: read this session's (or this project's) own history back. + * + * Compaction moves messages out of the working window; nothing removes them + * from the session. These two tools are how a model gets that text again, which + * is what makes the compaction wording truthful instead of a promise the model + * cannot act on (ADR 0300): + * + * - `recall` searches one session's complete transcript and reads any of its + * messages back verbatim, page by page. Tool results are readable this way + * even though they are not in the word index, which is how a narrowed tool + * result's remainder comes back. + * - `recall_project` does the same across the sessions of the project this + * session is bound to, and only those: the host resolves the project from the + * path the caller supplies and refuses a session that belongs to another + * project, so session existence never leaks across projects. + * + * The host owns matching and isolation; these functions own only what the model + * sees: the query's shape, the output bound, and the advice when nothing + * matched. They take a two-method adapter rather than the runtime itself, so + * both are testable without a session, a provider or a process. + */ +import { Type } from "@earendil-works/pi-ai"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +/** Host calls these tools make. `call` is the runtime's host bridge. */ +export type RecallHost = { + sessionId: string; + call: (method: string, params: Record) => Promise; +}; + +export const RECALL_TOOL_NAME = "recall"; +export const RECALL_PROJECT_TOOL_NAME = "recall_project"; + +/** + * The query rule is the host's, not a preference: it splits the query into + * words and every word must appear in the message, and unspaced Chinese counts + * as one word. A model that does not know this writes a paraphrase and gets + * nothing, so the description states the rule and what to do instead. + */ +export const RECALL_TOOL_DESCRIPTION = + "Search this session's complete message history, including messages that compaction moved out of the working window, and read any message back verbatim. How `query` is read: it is split into words and every word must appear in the message (case-insensitive); unspaced Chinese text counts as one word and therefore matches only literally, so a short distinctive phrase finds more than a sentence. Write the query in the words of the person who wrote the message — an identifier, an error string, a file path, or their own phrasing — not your paraphrase of it. Results are best matches first. Passing `messageId` reads that message's text in bounded pages, which is how a tool result (not in the word index) is read back; page forward with the offset the answer reports."; + +export const RECALL_PROJECT_TOOL_DESCRIPTION = + "Search the other sessions of this project and read any of their messages back verbatim. Scope is the project this session is bound to; a session of another project is not reachable this way. `query` follows the same rule as `recall` (every word must appear; unspaced Chinese matches literally). Passing `sessionId` reads that session's messages, newest page first, and `beforeSeq` pages backwards; passing `sessionId` with `messageId` reads one message by id, which is how the rest of a truncated project row comes back."; + +export const RECALL_DEFAULT_MATCHES = 10; +export const RECALL_MAX_MATCHES = 25; +/** Output bound: one answer must not become the next compaction's reason. */ +export const RECALL_MAX_OUTPUT_CHARS = 16_384; +export const RECALL_READ_DEFAULT_CHARS = 8_000; +export const RECALL_READ_MAX_CHARS = 20_000; +export const RECALL_PROJECT_READ_DEFAULT = 20; +export const RECALL_PROJECT_READ_MAX = 50; + +/** One lexical hit as the host returns it. */ +export type RecallToolHit = { + id: string; + role: string; + createdAt: string; + index: number; + snippet: string; +}; + +export type RecallHostResult = { + hits: RecallToolHit[]; + totalMessages: number; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hostMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * What the model reads for a search. A hit line carries enough to decide + * whether to read the message in full (index, role, timestamp, id) plus the + * bounded snippet, and the empty answer says what to try instead — a bare + * "no matches" teaches nothing and invites the same query again. + */ +export function recallAnswerText( + result: RecallHostResult, + query: string, + toolName: string = RECALL_TOOL_NAME, +): string { + const hits = Array.isArray(result.hits) ? result.hits : []; + const total = Number.isFinite(result.totalMessages) ? result.totalMessages : 0; + if (hits.length === 0) { + return ( + `No message in this session contains every word of "${query}" ` + + `(${total} messages searched).\n\n` + + "Try again with the words as they were written — an identifier, an error " + + "string, a file path, or a short distinctive phrase — rather than a " + + "paraphrase, and remember that an unspaced Chinese sentence matches only " + + `literally. Passing messageId instead reads one message you already know the id of.` + ); + } + const lines = hits.map((hit) => { + const header = `[${hit.index}] ${hit.role} ${hit.createdAt} (id: ${hit.id})`; + return `${header}\n${hit.snippet}`; + }); + const shown = `${hits.length} of ${total} messages match, best first`; + let text = `${shown}.\n\n${lines.join("\n\n")}`; + if (hits.length >= RECALL_MAX_MATCHES) { + text += `\n\n[only the best ${RECALL_MAX_MATCHES} matches are shown; narrow the query to see others]`; + } + if (text.length > RECALL_MAX_OUTPUT_CHARS) { + text = `${text.slice(0, RECALL_MAX_OUTPUT_CHARS)}\n\n[${toolName} output truncated]`; + } + return text; +} + +type MessageTextWindow = { + messageId: string; + role: string; + createdAt: string; + index: number; + totalChars: number; + offset: number; + text: string; + hasMore: boolean; + nextOffset: number; +}; + +/** Read one message of this session by id, in bounded pages. */ +async function readSessionMessage( + host: RecallHost, + params: Record, +): Promise { + const messageId = typeof params.messageId === "string" ? params.messageId : ""; + const offset = Math.max(0, Math.floor(Number(params.offset) || 0)); + const maxChars = Math.min( + RECALL_READ_MAX_CHARS, + Math.max(1, Math.floor(Number(params.limit) || RECALL_READ_DEFAULT_CHARS)), + ); + const window = (await host.call("session.readMessage", { + sessionId: host.sessionId, + messageId, + offset, + maxChars, + })) as MessageTextWindow; + const end = window.nextOffset; + const header = + `[${window.index}] ${window.role} ${window.createdAt} (id: ${window.messageId}) ` + + `characters ${window.offset}–${end} of ${window.totalChars}`; + const next = window.hasMore + ? `\n\n[more of this message remains: call ${RECALL_TOOL_NAME} again with messageId "${window.messageId}" and offset ${end}]` + : ""; + return `${header}\n\n${window.text}${next}`; +} + +/** `recall`: search this session, or read one of its messages by id. */ +export function buildRecallTool(host: RecallHost): AgentTool { + return { + name: RECALL_TOOL_NAME, + label: "Recall", + description: RECALL_TOOL_DESCRIPTION, + parameters: Type.Object({ + query: Type.Optional( + Type.String({ + description: + "Text to search for in message content (case-insensitive). Required unless messageId is given.", + }), + ), + messageId: Type.Optional( + Type.String({ + description: + "Read one message's text verbatim, page by page. Use an id returned by an earlier search; tool results are readable this way and are not in the search index.", + }), + ), + offset: Type.Optional( + Type.Number({ + description: + "With messageId: the character offset to start this page at (default 0). Page forward using the offset the answer reports.", + }), + ), + limit: Type.Optional( + Type.Number({ + description: `With messageId: characters to return (default ${RECALL_READ_DEFAULT_CHARS}, max ${RECALL_READ_MAX_CHARS}). Without it: maximum matches (default ${RECALL_DEFAULT_MATCHES}, max ${RECALL_MAX_MATCHES}).`, + }), + ), + }), + execute: async (_toolCallId, params) => { + const input = isRecord(params) ? params : {}; + const messageId = + typeof input.messageId === "string" ? input.messageId.trim() : ""; + try { + if (messageId) { + return { + content: [{ type: "text", text: await readSessionMessage(host, input) }], + details: { messageId }, + }; + } + const query = typeof input.query === "string" ? input.query.trim() : ""; + if (!query) { + return { + content: [ + { + type: "text", + text: 'Provide either `query` (text to search for) or `messageId` (read one message back).', + }, + ], + details: { error: "missing-query" }, + }; + } + const limit = Math.min( + RECALL_MAX_MATCHES, + Math.max(1, Math.floor(Number(input.limit) || RECALL_DEFAULT_MATCHES)), + ); + const result = (await host.call("session.recall", { + sessionId: host.sessionId, + query, + limit, + })) as RecallHostResult; + return { + content: [ + { type: "text", text: recallAnswerText(result, query, RECALL_TOOL_NAME) }, + ], + details: { + matches: Array.isArray(result.hits) ? result.hits.length : 0, + total: result.totalMessages, + }, + }; + } catch (error) { + const message = hostMessage(error); + return { + content: [{ type: "text", text: `recall failed: ${message}` }], + details: { error: message }, + }; + } + }, + } as AgentTool; +} + +/** `recall_project`: search and read the sessions bound to this project. */ +export function buildRecallProjectTool( + host: RecallHost, + projectPath: string | undefined, +): AgentTool { + return { + name: RECALL_PROJECT_TOOL_NAME, + label: "Recall project", + description: RECALL_PROJECT_TOOL_DESCRIPTION, + parameters: Type.Object({ + query: Type.Optional( + Type.String({ + description: + "Text to search for in message content (case-insensitive). Required unless sessionId is given.", + }), + ), + sessionId: Type.Optional( + Type.String({ + description: + "Read that session's messages, newest page first. Use an id returned by a search.", + }), + ), + messageId: Type.Optional( + Type.String({ + description: + "With sessionId: read that message's text verbatim, page by page. Tool results are readable this way and the rest of a truncated row comes back here.", + }), + ), + offset: Type.Optional( + Type.Number({ + description: + "With messageId: the character offset to start this page at (default 0).", + }), + ), + limit: Type.Optional( + Type.Number({ + description: `With sessionId: messages per page (default ${RECALL_PROJECT_READ_DEFAULT}, max ${RECALL_PROJECT_READ_MAX}). Otherwise: maximum sessions (default ${RECALL_DEFAULT_MATCHES}, max ${RECALL_MAX_MATCHES}).`, + }), + ), + beforeSeq: Type.Optional( + Type.Number({ + description: + "With sessionId: the seq of the oldest message already seen; the page returns messages older than it.", + }), + ), + }), + execute: async (_toolCallId, params) => { + const input = isRecord(params) ? params : {}; + try { + if (!projectPath) { + return { + content: [ + { + type: "text", + text: "This session has no project path, so other sessions are not reachable from it.", + }, + ], + details: { error: "no-project" }, + }; + } + const sessionId = + typeof input.sessionId === "string" ? input.sessionId.trim() : ""; + const messageId = + typeof input.messageId === "string" ? input.messageId.trim() : ""; + if (sessionId && messageId) { + const offset = Math.max(0, Math.floor(Number(input.offset) || 0)); + const window = (await host.call("session.readMessage", { + sessionId, + messageId, + offset, + maxChars: RECALL_READ_DEFAULT_CHARS, + projectPath, + })) as MessageTextWindow; + const end = window.nextOffset; + const header = + `[${window.index}] ${window.role} ${window.createdAt} (id: ${window.messageId}) ` + + `characters ${window.offset}–${end} of ${window.totalChars}`; + const next = window.hasMore + ? `\n\n[more of this message remains: call ${RECALL_PROJECT_TOOL_NAME} again with sessionId "${sessionId}", messageId "${window.messageId}" and offset ${end}]` + : ""; + return { + content: [{ type: "text", text: `${header}\n\n${window.text}${next}` }], + details: { sessionId, messageId: window.messageId }, + }; + } + if (sessionId) { + const limit = Math.min( + RECALL_PROJECT_READ_MAX, + Math.max(1, Math.floor(Number(input.limit) || RECALL_PROJECT_READ_DEFAULT)), + ); + const beforeSeq = Number.isFinite(Number(input.beforeSeq)) + ? Math.floor(Number(input.beforeSeq)) + : undefined; + const window = (await host.call("session.readProject", { + sessionId, + projectPath, + limit, + ...(beforeSeq !== undefined ? { beforeSeq } : {}), + })) as { + sessionId: string; + sessionTitle: string; + total: number; + hasMore: boolean; + messages: Array<{ + id: string; + seq: number; + role: string; + text: string; + createdAt: string; + toolName?: string; + truncated?: boolean; + }>; + }; + const messages = Array.isArray(window.messages) ? window.messages : []; + if (messages.length === 0) { + return { + content: [ + { + type: "text", + text: `Session "${sessionId}" has no messages visible through this project.`, + }, + ], + details: { sessionId, messages: 0 }, + }; + } + const body = messages + .map( + (message) => + `[seq ${message.seq}] ${message.role} ${message.createdAt} (id: ${message.id})` + + `${message.toolName ? ` ${message.toolName}` : ""}` + + `${message.truncated ? " [truncated row; read it by id]" : ""}\n${message.text}`, + ) + .join("\n\n"); + let text = + `Session "${window.sessionTitle}" (${window.sessionId}) — ${messages.length} of ${window.total} messages, oldest first.\n\n${body}`; + if (window.hasMore) { + const oldest = messages[0]; + text += `\n\nOlder messages remain: call ${RECALL_PROJECT_TOOL_NAME} again with the same sessionId and beforeSeq=${oldest.seq}.`; + } + if (text.length > RECALL_MAX_OUTPUT_CHARS) { + text = `${text.slice(0, RECALL_MAX_OUTPUT_CHARS)}\n[${RECALL_PROJECT_TOOL_NAME} output truncated]`; + } + return { + content: [{ type: "text", text }], + details: { + sessionId, + messages: messages.length, + total: window.total, + hasMore: window.hasMore, + }, + }; + } + const query = typeof input.query === "string" ? input.query.trim() : ""; + if (!query) { + return { + content: [ + { + type: "text", + text: "Provide `query` (text to search for) or `sessionId` (read one session back).", + }, + ], + details: { error: "missing-query" }, + }; + } + const limit = Math.min( + RECALL_MAX_MATCHES, + Math.max(1, Math.floor(Number(input.limit) || RECALL_DEFAULT_MATCHES)), + ); + const answer = (await host.call("search.query", { + query, + limit, + projectPath, + })) as { + hits?: Array<{ + sessionId: string; + sessionTitle: string; + messageId: string; + role: string; + snippet: string; + createdAt: string; + }>; + }; + const hits = Array.isArray(answer.hits) ? answer.hits : []; + if (hits.length === 0) { + return { + content: [ + { + type: "text", + text: + `No session of this project contains every word of "${query}".\n\n` + + "Sessions surface once, on their best-matching row, so try the words as they were written (an identifier, an error string, a short phrase).", + }, + ], + details: { sessions: 0 }, + }; + } + const lines = hits.map( + (hit) => + `${hit.sessionTitle || hit.sessionId} (sessionId: ${hit.sessionId})\n` + + ` best match: [${hit.role} ${hit.createdAt}] (id: ${hit.messageId})\n ${hit.snippet}`, + ); + let text = `${hits.length} session(s) of this project match, best first.\n\n${lines.join("\n\n")}`; + text += `\n\nRead one back with ${RECALL_PROJECT_TOOL_NAME} and its sessionId.`; + if (text.length > RECALL_MAX_OUTPUT_CHARS) { + text = `${text.slice(0, RECALL_MAX_OUTPUT_CHARS)}\n[${RECALL_PROJECT_TOOL_NAME} output truncated]`; + } + return { + content: [{ type: "text", text }], + details: { sessions: hits.length }, + }; + } catch (error) { + const message = hostMessage(error); + return { + content: [{ type: "text", text: `recall_project failed: ${message}` }], + details: { error: message }, + }; + } + }, + } as AgentTool; +} diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index cc0346e2bb..daf289a262 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -1821,6 +1821,9 @@ describe("DesktopAgentRuntime deferred tool catalog", () => { const tools = (runtime as any).agent.state.tools as Array<{ name: string }>; const names = tools.map((tool) => tool.name); + // Recall rides with the core set: it is the read side of compaction, and a + // capability the model must go looking for is one it will not reach for + // while it is trying to recover what a summary left out. expect(names).toEqual([ "Read", "Bash", @@ -1831,6 +1834,8 @@ describe("DesktopAgentRuntime deferred tool catalog", () => { "EnterPlanMode", "EnterGoalMode", "new_context", + "recall", + "recall_project", "ToolSearch", ]); expect(names).not.toContain("A2A"); @@ -4568,7 +4573,14 @@ describe("DesktopAgentRuntime compaction restore", () => { "compactionSummary", "user", ]); - expect(agentMessages[0].summary).toBe("Older work was summarized."); + // The projected summary carries the recall pointer (and, when the + // checkpoint has one, its ledger) on top of the stored text: the stored + // record never changes, the projection says where the messages went and how + // to read them again (ADR 0300). + expect(agentMessages[0].summary).toContain("Older work was summarized."); + expect(agentMessages[0].summary).toContain( + "retrievable verbatim via the recall tool", + ); expect((runtime as any).fullEntries).toHaveLength(2); await runtime.dispose(); }); @@ -4988,7 +5000,7 @@ describe("DesktopAgentRuntime per-turn context protection", () => { await runtime.dispose(); }); - it("warns once more right before the boundary, then not again", async () => { + it("warns once before the boundary, and once more after a checkpoint", async () => { const runtime = createRuntime(); vi.spyOn(runtime as any, "contextBudget").mockReturnValue({ tokens: 223_000, @@ -5000,13 +5012,18 @@ describe("DesktopAgentRuntime per-turn context protection", () => { const first = await (runtime as any).prepareNextTurn(nextTurn); const second = await (runtime as any).prepareNextTurn(nextTurn); + // One tier, matching Codex's first: the sharper second reminder is gone + // because its claim ("unsummarized detail will not be available + // afterwards") is false while `recall` exists (ADR 0300). expect(first.context.systemPrompt).toContain( + "tokens of working context remain before this conversation is compacted", + ); + expect(first.context.systemPrompt).not.toContain( "the next request compacts this conversation", ); expect(second.context.systemPrompt).not.toContain(""); - // Installing a checkpoint opens a new window, so both tiers come back. + // Installing a checkpoint opens a new window, so the reminder comes back. (runtime as any).contextReminderClaimed = false; - (runtime as any).contextFallbackReminderClaimed = false; const afterCheckpoint = await (runtime as any).prepareNextTurn(nextTurn); expect(afterCheckpoint.context.systemPrompt).toContain(""); @@ -5340,7 +5357,7 @@ describe("DesktopAgentRuntime per-turn context protection", () => { await runtime.dispose(); }); - it("persists a retained-tail fallback when automatic summary generation fails", async () => { + it("persists a deterministic record when automatic summary generation fails", async () => { const onEvent = vi.fn(); const host = { call: vi.fn().mockResolvedValue(undefined) }; const runtime = createRuntime({ @@ -5378,16 +5395,23 @@ describe("DesktopAgentRuntime per-turn context protection", () => { const checkpoint = host.call.mock.calls[0]?.[0] === "session.appendCompaction" ? (host.call.mock.calls[0]?.[1] as any).compaction : undefined; - expect(checkpoint).toEqual( + // The deterministic record is the first rung: the model summary failed, so + // the durable checkpoint carries what no later request can reconstruct — + // the goal verbatim, the failures nothing repaired, the next step — instead + // of only a notice. `strategy` keeps it readable as a real summary. + expect(host.call).toHaveBeenCalledTimes(1); + expect(checkpoint.throughMessageId).toBe("recent-user"); + expect(checkpoint.details).toEqual( expect.objectContaining({ - throughMessageId: "recent-user", - details: expect.objectContaining({ - fallback: "retained_tail", - failureCode: "CONTEXT_COMPACTION_FAILED", - retainedTailMode: "completed_turn", - }), + strategy: "trajectory", + failureCode: "CONTEXT_COMPACTION_FAILED", + retainedTailMode: "completed_turn", + trajectory: expect.objectContaining({ messages: 2, goal: true }), }), ); + expect(checkpoint.summary).toContain("continue the task"); + expect(checkpoint.summary).toContain("## Goal"); + expect(checkpoint.summary).toContain(COMPACTION_FALLBACK_MARKER); expect((runtime as any).fullEntries).toHaveLength(2); expect((runtime as any).agent.state.messages.filter((message: any) => message.role !== "system")[0]).toEqual( expect.objectContaining({ role: "compactionSummary" }), @@ -5407,6 +5431,57 @@ describe("DesktopAgentRuntime per-turn context protection", () => { await runtime.dispose(); }); + it("still writes the retained-tail notice when the record cannot fit", async () => { + // The deterministic record is tried first because it is free, but it is not + // the last resort: when it does not fit the safe budget the notice below it + // still runs, so a summary failure never ends without a checkpoint. + const host = { call: vi.fn().mockResolvedValue(undefined) }; + const runtime = createRuntime({ + host, + history: [ + { + id: "old-user", + role: "user", + content: "older task context", + createdAt: "2026-07-28T00:00:00Z", + status: "complete", + }, + { + id: "recent-user", + role: "user", + content: "continue the task", + createdAt: "2026-07-28T00:00:01Z", + status: "complete", + }, + ], + }); + vi.spyOn(runtime as any, "generateCompaction").mockResolvedValue({ + ok: false, + error: { + code: "summarization_failed", + message: "provider terminated the summary request", + }, + }); + const persist = vi.spyOn(runtime as any, "persistCheckpoint"); + persist.mockResolvedValueOnce("oversized"); + persist.mockResolvedValue("persisted"); + + await expect( + (runtime as any).runCompaction("threshold", false), + ).resolves.toBe(true); + + expect(persist).toHaveBeenCalledTimes(2); + const first = persist.mock.calls[0]?.[0] as any; + const second = persist.mock.calls[1]?.[0] as any; + expect(first.details.strategy).toBe("trajectory"); + // The last resort keeps its own identity rather than being a copy of the + // record that could not fit. + expect(second.details.fallback).toBe("retained_tail"); + expect(second.details.strategy).toBeUndefined(); + expect(second.summary).not.toContain("## Goal"); + await runtime.dispose(); + }); + it("shrinks a terminal checkpoint tail when a new prompt leaves no history to summarize", async () => { const host = { call: vi.fn().mockResolvedValue(undefined) }; const runtime = createRuntime({ @@ -5447,16 +5522,21 @@ describe("DesktopAgentRuntime per-turn context protection", () => { true, ); + // No new history to summarize, so the carried summary is the only older + // context there is: the durable checkpoint must carry it forward intact and + // the deterministic record must describe this range on top of it. expect(generateCompaction).not.toHaveBeenCalled(); expect(host.call).toHaveBeenCalledWith( "session.appendCompaction", expect.objectContaining({ compaction: expect.objectContaining({ - details: expect.objectContaining({ fallback: "retained_tail" }), + details: expect.objectContaining({ strategy: "trajectory" }), summary: expect.stringContaining("The previous task summary."), }), }), ); + const appended = (host.call.mock.calls[0]?.[1] as any).compaction; + expect(appended.summary).toContain("## Goal"); await runtime.dispose(); }); @@ -5568,11 +5648,15 @@ describe("DesktopAgentRuntime per-turn context protection", () => { "session.appendCompaction", expect.objectContaining({ compaction: expect.objectContaining({ - details: expect.objectContaining({ fallback: "retained_tail" }), + details: expect.objectContaining({ strategy: "trajectory" }), summary: expect.stringContaining("The earlier task summary."), }), }), ); + // The carried history is recovered from the notice marker, so a chained + // fallback does not cement this range's description as history (#224). + const appended = (host.call.mock.calls[0]?.[1] as any).compaction; + expect(appended.summary).toContain("## Goal"); await runtime.dispose(); }); @@ -5921,6 +6005,9 @@ describe("DesktopAgentRuntime inline context compaction", () => { throughMessageId: "recent-user", generation: 1, summaryTokens: 7, + // Stamped by persistCheckpoint so the ring can lead with the new + // window before any request reports it. + tokensAfter: expect.any(Number), summarized: true, }, }), @@ -6767,7 +6854,7 @@ describe("DesktopAgentRuntime subagents", () => { delegationIds: [secondId], }); // A settled run's own status reaches TaskWait unchanged; `truncated` went - // with the turn limit (ADR 0253), so the partial-report status a stopped + // with the turn limit, so the partial-report status a stopped // delegate reports is `aborted`. expect(aborted.details).toMatchObject({ delegations: [{ delegationId: secondId, status: "aborted" }], @@ -8703,3 +8790,103 @@ describe("context estimate calibration", () => { }); }); }); + +describe("tool-result tiering under context pressure", () => { + /** A stored Read window the way host-core renders one: header, then `N: line`. */ + const readWindow = (lines: number): string => + `[src/app.ts#a1b2]\n${Array.from( + { length: lines }, + (_value, index) => `${index + 1}:${"x".repeat(200)}`, + ).join("\n")}`; + + const readCall = (id: string, path: string) => ({ + role: "assistant", + content: [{ type: "toolCall", id, name: "Read", arguments: { file_path: path } }], + }); + const readResult = (id: string, text: string) => ({ + role: "toolResult", + toolCallId: id, + toolName: "Read", + content: [{ type: "text", text }], + }); + const budget = (tokens: number) => ({ + tokens, + hardLimit: 224_000, + requestHeadroom: 32_000, + keepRecentTokens: 44_800, + }); + + /** + * An old row, then nine newer file-touching calls. The working set is the + * newest eight paths, so `src/old.ts` is outside it and its row is eligible + * while its siblings are not. + */ + const viewWithOldRead = () => + [ + readCall("call-old", "src/old.ts"), + readResult("call-old", readWindow(200)), + ...Array.from({ length: 9 }, (_value, index) => [ + readCall(`call-${index}`, `src/f${index}.ts`), + readResult(`call-${index}`, "x".repeat(50)), + ]).flat(), + ] as never[]; + + it("leaves the view untouched below the pressure gate", async () => { + const runtime = createRuntime(); + vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(10_000)); + const view = viewWithOldRead(); + + expect((runtime as any).narrowToolResultsUnderPressure(view)).toBe(view); + await runtime.dispose(); + }); + + it("tiers an old Read result once the context is under pressure", async () => { + const runtime = createRuntime(); + vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(160_000)); + const view = viewWithOldRead(); + const narrowed = (runtime as any).narrowToolResultsUnderPressure(view); + + expect(narrowed).not.toBe(view); + const first = (narrowed[1] as any).content[0].text as string; + expect(first).toContain("[tool result narrowed:"); + expect(first).toContain('Continue with Read path="src/old.ts" offset='); + // The rows the working set protects keep their identity. + expect(narrowed[3]).toBe(view[3]); + await runtime.dispose(); + }); + + it("tiers a spilled shell result without consulting the working set", async () => { + const runtime = createRuntime(); + vi.spyOn(runtime as any, "contextBudget").mockReturnValue(budget(160_000)); + const spill = + "y".repeat(30_000) + + "\n[truncated: kept the first 4000 of 51234 lines; limit 4000 lines / 96KB. " + + "Full output saved to C:\\scratch\\s1\\tool-output\\bash-1-1.log — " + + "Grep it, or Read it with offset/limit.]"; + const view = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "Bash", arguments: { command: "npm test" } }], + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "Bash", + content: [{ type: "text", text: spill }], + }, + ...Array.from({ length: 6 }, () => ({ + role: "toolResult", + toolCallId: "recent", + toolName: "Bash", + content: [{ type: "text", text: "x".repeat(50) }], + })), + ] as never[]; + const narrowed = (runtime as any).narrowToolResultsUnderPressure(view); + + expect(narrowed).not.toBe(view); + const first = (narrowed[1] as any).content[0].text as string; + expect(first).toContain("tool-output\\bash-1-1.log"); + expect(first).toContain("Read it with offset/limit, or Grep it"); + await runtime.dispose(); + }); +}); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 1877654acd..ad2dc7b836 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -45,6 +45,15 @@ import { type UserMessage, } from "@earendil-works/pi-ai"; import { + clampDynamicContextPercent, + clampEarlyCompactionDelaySeconds, + clampEarlyCompactionPercent, + clampSleepTimeRunsPerHour, + DYNAMIC_CONTEXT_DEFAULT_PERCENT, + EARLY_COMPACTION_DEFAULT_DELAY_SECONDS, + EARLY_COMPACTION_DEFAULT_PERCENT, + EARLY_COMPACTION_DEFAULT_SILENT, + SLEEP_TIME_DEFAULT_RUNS_PER_HOUR, DEFAULT_COMMAND_TIMEOUT_MS, OAUTH_AUTH_KIND, type TrustedExtensionCommand, @@ -200,6 +209,19 @@ import { estimateSummaryPromptTokens, reduceSummaryInput, } from "./compaction-summary-input.js"; +import { + buildTrajectorySummary, + extractGoal, + extractNextSteps, + extractOpenItems, + TRAJECTORY_MAX_PREVIOUS_CHARS, +} from "./compaction-trajectory.js"; +import { + buildRecallProjectTool, + buildRecallTool, + RECALL_PROJECT_TOOL_NAME, + RECALL_TOOL_NAME, +} from "./recall-tools.js"; import { mergeProviderHeaders, providerHeadersEqual, @@ -224,6 +246,10 @@ import { ContextEstimateCalibration, type ContextCalibration, } from "./context-calibration.js"; +import { + narrowToolResults, + workingSetPathsFrom, +} from "./tool-result-tier.js"; import { rebuildNodeNetworkTransport } from "./node-proxy.js"; import { @@ -752,7 +778,7 @@ function resolveCompactionStrategy( */ const CONTEXT_ROLLOVER_SUMMARY = [ "[context rollover: a new context window was started without summarizing conversation history]", - "Earlier messages in this session are not part of this request. The complete transcript is still available to the user, and the environment is unchanged.", + "Earlier messages in this session are not part of this request. Nothing was deleted: the complete transcript is still in this session, and the recall tool reads any earlier message back verbatim. The environment is unchanged.", "Ask before assuming anything about work that is not visible here.", ].join("\n\n"); @@ -780,16 +806,16 @@ const CONTEXT_COMPACTION_TOOL_REPLY: Record = { }; /** - * Two-tier budget reminder, matching Codex's `TokenBudgetReminder` and - * `AutoCompactFallbackPrompt`. Codex reads both thresholds and both texts from - * per-model metadata; we have no such feed, so the thresholds are derived from - * the same hard limit the compaction guard uses and the texts are ours. + * One budget reminder, matching Codex's `TokenBudgetReminder` first tier. + * Codex also has an `AutoCompactFallbackPrompt` — a second, sharper reminder + * just before the boundary, telling the model that unsummarized detail "will not + * be available afterwards". That sentence is no longer true here: compaction + * does not delete anything, and `recall` reads any earlier message back + * verbatim, so the tier is gone rather than rephrased (ADR 0300). */ const CONTEXT_REMINDER_MIN_TOKENS = 8_000; const CONTEXT_REMINDER_MAX_TOKENS = 32_000; const CONTEXT_REMINDER_RATIO = 0.15; -/** Close enough to the boundary that the next turn is likely to cross it. */ -const CONTEXT_FALLBACK_REMINDER_TOKENS = 2_000; function contextBudgetReminder(remaining: number): string { return [ @@ -801,16 +827,10 @@ function contextBudgetReminder(remaining: number): string { ].join("\n"); } -function contextFallbackReminder(): string { - return [ - "", - "The working context is at its limit: the next request compacts this conversation automatically.", - "Write down now, in this turn, whatever must survive — file paths, decisions, and the exact next step — because unsummarized detail will not be available afterwards.", - "", - ].join("\n"); -} - +const IDLE_COMPACTION_DELAY_MS = EARLY_COMPACTION_DEFAULT_DELAY_SECONDS * 1_000; +const IDLE_COMPACTION_OCCUPANCY_RATIO = EARLY_COMPACTION_DEFAULT_PERCENT / 100; +const IDLE_COMPACTION_SILENT = EARLY_COMPACTION_DEFAULT_SILENT; export type PluginToolDef = { /** Full exposed name (`plugin__`, D015). */ name: string; @@ -857,6 +877,25 @@ export type AgentRuntimeOptions = { * `PI_DESKTOP_COMPACTION_STRATEGY` and otherwise summarizes. */ compactionStrategy?: CompactionStrategy; + /** + * Dynamic context gate (D447): the share of the hard limit at which old tool + * results start being narrowed. Precedence: this option (tests) beats the + * launch payload's provider binding, which beats the default. + */ + dynamicContext?: { enabled: boolean; thresholdPercent: number }; + /** + * Early background compaction (ADR 0301): the share of the hard limit at + * which a settled, idle session compacts in the background, how long it waits + * and whether a successful pass stays silent. Same precedence as the gate. + */ + earlyCompaction?: { + enabled: boolean; + thresholdPercent: number; + delaySeconds: number; + silent: boolean; + }; + /** Sleep-time digest (ADR 0301). Same precedence as the gate; off by default. */ + sleepTime?: { enabled: boolean; maxRunsPerHour: number }; /** Plugin agent tools to expose to the model this session. */ pluginTools?: PluginToolDef[]; /** Plugin skills advertised in the system prompt and loaded via `Skill`. */ @@ -977,6 +1016,31 @@ type CheckpointPersistResult = "persisted" | "oversized" | "failed"; type CompactionRetentionMode = "active_turn" | "completed_turn"; +const LEDGER_MAX_FILES = 40; +const LEDGER_MAX_COMMANDS = 20; +const LEDGER_MAX_COMMAND_CHARS = 200; + +/** The mechanical record stored on a checkpoint's `details.ledger`. */ +type SessionLedger = { + filesRead: string[]; + filesModified: string[]; + commands: string[]; + messages: number; + toolCalls: number; + /** + * I (CONTEXT-V3-PLAN §4.I): the re-anchor half. Files and commands tell a + * next window where the work happened; these two tell it what the work + * *was* and what is still open, which is what the ledger lacked. Optional + * because every checkpoint written before this change has neither. + */ + goal?: string; + openItems?: string[]; +}; + +/** Bounds for the two re-anchor fields the ledger carries. */ +const LEDGER_MAX_GOAL_CHARS = 400; +const LEDGER_MAX_OPEN_ITEMS = 5; + /** * A pi preparation plus the anchor the checkpoint is filed against. * @@ -1669,13 +1733,64 @@ export class DesktopAgentRuntime { private compactionInProgress = false; /** The in-flight checkpoint was cut short by Stop/dispose, not by a failure. */ private compactionAborted = false; + + private idleCompaction: { + enabled: boolean; + delayMs: number; + occupancyRatio: number; + /** Keep a successful pass out of the routine warning toast. */ + silent: boolean; + } = { + enabled: true, + delayMs: IDLE_COMPACTION_DELAY_MS, + occupancyRatio: IDLE_COMPACTION_OCCUPANCY_RATIO, + silent: IDLE_COMPACTION_SILENT, + }; + /** + * Sleep-time digest. Off unless the model binding turns it on; it is a + * deterministic extraction, so it never spends a provider call, and the quota + * bounds how much transcript it may add per hour of session time. + */ + private sleepTime: { enabled: boolean; maxRunsPerHour: number } = { + enabled: false, + maxRunsPerHour: SLEEP_TIME_DEFAULT_RUNS_PER_HOUR, + }; + /** Timestamps of the digests taken, used for the hourly quota. */ + private sleepRuns: number[] = []; + /** + * The newest digest, kept in memory as well as on disk: a summary request + * that never returns falls back to the deterministic record, and this is the + * same record captured earlier — when the range was smaller, so its goal + * lines are less truncated. + */ + private sleepDigest: { digest: string; throughMessageId: string } | undefined; + /** + * Dynamic context gate (D): the share of the hard limit at which old tool + * results start being narrowed. Overridden per session from the model + * binding; the defaults reproduce the previous constant behaviour. + */ + private dynamicContext: { enabled: boolean; thresholdPercent: number } = { + enabled: true, + thresholdPercent: DYNAMIC_CONTEXT_DEFAULT_PERCENT, + }; + /** True while a background idle compaction is running. */ + private idleCompactionActive = false; + /** Pending idle pre-compaction timer; any new activity cancels it. */ + private idleCompactionTimer: ReturnType | undefined; + /** The in-flight background pass, so a prompt can wait for its unwind. */ + private idleCompactionRun?: Promise; + /** + * Set when real work asks a pass to stand down; the pass checks it before + * its summary request so a prompt never waits behind a provider call it + * did not cause. Cleared when the next pass is armed. + */ + private idleCompactionYielded = false; /** Set by the `new_context` tool, consumed at the next turn boundary. */ private pendingModelCompaction = false; /** One-shot request to finish the current turn at the next boundary. */ private gracefulStopRequested = false; /** Codex's `claim_*` flags: one of each reminder per context window. */ private contextReminderClaimed = false; - private contextFallbackReminderClaimed = false; private activeToolProgressCleanups = new Set<(flush: boolean) => void>(); private hostCloseUnsubscribe?: () => void; private turnSubagentUsage?: MessageUsage; @@ -1717,6 +1832,52 @@ export class DesktopAgentRuntime { this.compactionEnabled = compactionEnabled(opts.compactionSettings); this.compactionStrategy = resolveCompactionStrategy(opts.compactionStrategy); + // Precedence: an explicit option (tests) beats the launch payload, which + // beats the historical constants. + const configuredDynamicContext = + opts.dynamicContext ?? opts.provider.dynamicContext; + if (configuredDynamicContext) { + this.dynamicContext = { + enabled: configuredDynamicContext.enabled !== false, + thresholdPercent: clampDynamicContextPercent( + configuredDynamicContext.thresholdPercent, + ), + }; + } + + // Same precedence as the gate: an explicit option (tests) beats the launch + // payload, which beats the constants. The threshold is a share of the hard + // limit and is clamped by the shared bounds, so the runtime and the + // settings pane cannot disagree about what "75 %" means. + const configuredEarlyCompaction = + opts.earlyCompaction ?? opts.provider.earlyCompaction; + if (configuredEarlyCompaction) { + this.idleCompaction = { + enabled: configuredEarlyCompaction.enabled !== false, + delayMs: + clampEarlyCompactionDelaySeconds( + configuredEarlyCompaction.delaySeconds, + ) * 1_000, + occupancyRatio: + clampEarlyCompactionPercent( + configuredEarlyCompaction.thresholdPercent, + ) / 100, + silent: configuredEarlyCompaction.silent !== false, + }; + } + + // I4's setting rides the same precedence: an explicit option (tests) beats + // the launch payload, which beats the default — which is off. + const configuredSleepTime = opts.sleepTime ?? opts.provider.sleepTime; + if (configuredSleepTime) { + this.sleepTime = { + enabled: configuredSleepTime.enabled === true, + maxRunsPerHour: clampSleepTimeRunsPerHour( + configuredSleepTime.maxRunsPerHour, + ), + }; + } + this.rebuildToolCatalog(); const model = buildProviderModel(this.provider); this.model = model; @@ -1925,9 +2086,12 @@ Delegation rules: // The provider's rule that a tool-call id is unique is enforced here, on // the last view before the wire: the request is the only place it can be // guaranteed for both a rebuilt context and one that grew in this process. + // Old tool results are tiered in the same place: that pass is a no-op below + // its pressure gate and returns the same array when nothing qualified, so + // an ordinary request is byte-identical to what it was before it existed. convertToLlm: (messages) => alignRetainedReasoningIdentity( - convertToLlm(this.dropDuplicateToolCalls(messages)), + convertToLlm(this.narrowToolResultsUnderPressure(this.dropDuplicateToolCalls(messages))), this.reasoningReplayIdentity(), ), prepareNextTurnWithContext: (context, signal) => @@ -3388,6 +3552,25 @@ Delegation rules: const contextTools = this.compactionEnabled ? [this.buildContextCompactionTool()] : []; + // Recall (ADR 0300): the read side of compaction. Both live beside the + // compaction tool because they are what makes its wording truthful — a + // message moved out of the window is still readable here. `recall` is the + // session's own history; `recall_project` is bounded by the project this + // session is bound to, and the host refuses a foreign session id, so the + // project scope is enforced where the data lives. + const recallTools = [ + buildRecallTool({ + sessionId: this.sessionId, + call: (method, params) => this.host.call(method, params) as Promise, + }), + buildRecallProjectTool( + { + sessionId: this.sessionId, + call: (method, params) => this.host.call(method, params) as Promise, + }, + this.projectPath, + ), + ]; // Trusted extension tools are non-core: the per-mode allowlist and // ToolSearch deferral treat them like plugin tools (spec 16 §7). const extensionTools = this.extensionRunner?.getAgentTools() ?? []; @@ -3399,6 +3582,7 @@ Delegation rules: ...modeTools, ...subagentTools, ...contextTools, + ...recallTools, ...extensionTools, ]; } @@ -3464,6 +3648,10 @@ Delegation rules: "Bash", ASK_TOOL_NAME, CONTEXT_COMPACTION_TOOL_NAME, + // Recall is a read of this session's own history, so it is safe in a + // contract mode for the same reason Read is. + RECALL_TOOL_NAME, + RECALL_PROJECT_TOOL_NAME, SUBMIT_TOOL_NAMES[kind], ]).has(name); } @@ -3471,6 +3659,11 @@ Delegation rules: private isCoreTool(name: string): boolean { return ( name === CONTEXT_COMPACTION_TOOL_NAME || + // Recall stays in the core set: it is the read side of compaction, and a + // capability the model has to go looking for is one it will not use when + // it is trying to recover what a summary left out. + name === RECALL_TOOL_NAME || + name === RECALL_PROJECT_TOOL_NAME || MODE_TRANSITION_TOOL_NAMES.has(name) || // The whole delegation lifecycle stays in the core set rather than the // on-demand catalog: a capability the model has to go looking for is one @@ -5828,11 +6021,209 @@ Delegation rules: additionalMessages: AgentMessage[] = [], ): boolean { const context = this.liveSessionContext(); - const messages = [...context.messages, ...additionalMessages]; + // Decide on the view the request would actually carry: narrowing old tool + // results shrinks it, and reading the un-narrowed projection here would + // make the saving invisible to this check, so compaction would fire while + // real room remained. + const messages = [ + ...this.narrowToolResultsUnderPressure(context.messages), + ...additionalMessages, + ]; const budget = this.contextBudget(messages); return this.compactionEnabled && budget.tokens >= budget.hardLimit; } + /** + * Shorten old tool results in an outgoing view, but only once the context is + * actually under pressure — below the gate the view comes back untouched, so + * the common case pays nothing. The full text of every narrowed result stays + * reachable through the pointer the pass embeds (see `tool-result-tier.ts`). + */ + private narrowToolResultsUnderPressure(messages: AgentMessage[]): AgentMessage[] { + if (messages.length === 0) return messages; + // The gate is the one place that decides this share, so the outgoing request + // and every budget decision agree; off means nothing is narrowed. + const gate = this.dynamicContextGate(); + if (!Number.isFinite(gate)) return messages; + const budget = this.contextBudget(messages); + if (budget.tokens < budget.hardLimit * gate) { + return messages; + } + // The newest file-touching calls define what the task is about right now; a + // result for a file the session re-opened stays whole even when its row is + // old. `[keep]` immunity, the excluded tool families and the clear-at-least + // floor come from the module's own defaults. + return narrowToolResults(messages, { + workingSetPaths: workingSetPathsFrom(messages), + }).messages; + } + + /** + * The configured share of the hard limit at which narrowing starts, or + * `Infinity` when the gate is switched off (D). One place decides it, so the + * outgoing request and every budget decision agree. + */ + private dynamicContextGate(): number { + if (!this.dynamicContext.enabled) return Number.POSITIVE_INFINITY; + return clampDynamicContextPercent(this.dynamicContext.thresholdPercent) / 100; + } + + /** + * Idle pre-compaction: after a run settles this close to the hard limit, + * compact in the background so the next prompt does not wait for the + * summary. Best-effort - any new activity cancels the timer or stands a + * running pass down, and a failed + * idle compaction only reports through the normal compaction_end; the + * next prompt's own protection still runs. + */ + private scheduleIdleCompaction(): void { + this.cancelIdleCompaction(); + // Arming is what clears a previous stand-down: the next idle window gets + // a fresh decision instead of inheriting the last prompt's veto. + this.idleCompactionYielded = false; + if (this.disposed) return; + if (!this.idleCompaction.enabled) return; + if (!this.compactionEnabled) return; + // Same view as the next-request check, so narrowing postpones the + // idle pass too instead of leaving it to fire on the un-narrowed size. + const budget = this.contextBudget( + this.narrowToolResultsUnderPressure(this.agent.state.messages), + ); + if (budget.tokens < budget.hardLimit * this.idleCompaction.occupancyRatio) { + return; + } + // I4: the digest is taken here, not on the timer. It spends nothing, and + // taking it as soon as the pass is armed means it exists *before* the + // summary attempt — which is the whole point, after an incident where that + // attempt never came back. + void this.takeSleepDigest().catch(() => undefined); + const timer = setTimeout(() => { + this.idleCompactionTimer = undefined; + const run = this.runIdleCompaction().catch(() => undefined); + this.idleCompactionRun = run; + void run.then(() => { + if (this.idleCompactionRun === run) this.idleCompactionRun = undefined; + }); + }, this.idleCompaction.delayMs); + timer.unref?.(); + this.idleCompactionTimer = timer; + } + + /** + * I4: write the deterministic digest of where the session stands. + * + * Four gates, in the order that matters. The setting must be on. The session + * must not be in a state a digest would misrepresent: streaming, already + * compacting, or waiting for the user to approve a plan. The hourly quota must + * have room. And the transcript must have a message to point at. + * + * No provider call is involved, so the only failure left is storage, which + * must not fail anything else — the caller ignores it, exactly as it ignores a + * failed idle compaction. + */ + private async takeSleepDigest(): Promise { + if (!this.sleepTime.enabled) return; + if (this.disposed || this.agent.state.isStreaming) return; + if (this.compactionInProgress || this.idleCompactionActive) return; + if (this.planningState === "awaiting_approval") return; + const messages = this.agent.state.messages; + // The durable coordinate is the entry id: the compaction path names its + // range the same way, and a pi message carries no id of its own. + const entries = this.entriesWithCompaction(); + const throughMessageId = entries[entries.length - 1]?.id; + if (typeof throughMessageId !== "string" || throughMessageId.length === 0) { + return; + } + const now = Date.now(); + this.sleepRuns = this.sleepRuns.filter((at) => now - at < 3_600_000); + if (this.sleepRuns.length >= this.sleepTime.maxRunsPerHour) return; + + const goal = extractGoal(messages); + const openItems = extractOpenItems(messages); + const nextSteps = extractNextSteps(messages); + const digest = [ + "## Goal", + goal ?? "(no user request was recorded yet)", + "", + "## Progress", + `${messages.length} messages were in the window when this digest was taken; the session ledger below lists the files and commands for this range.`, + "", + "## Blocked", + openItems.length > 0 ? openItems.map((item) => `- ${item}`).join("\n") : "Nothing was blocked when this was taken.", + ...(nextSteps.length > 0 + ? ["", "## Next", ...nextSteps.map((step) => `- ${step}`)] + : []), + ].join("\n"); + // Quota counts the attempt: a digest that was built and rejected by storage + // still spent the transcript read, and a retry loop is not wanted here. + this.sleepRuns.push(now); + this.sleepDigest = { digest, throughMessageId }; + await this.host.call("session.appendSleep", { + sessionId: this.sessionId, + record: { + id: `sleep-${now}-${throughMessageId.slice(0, 8)}`, + digest, + throughMessageId, + messageCount: entries.length, + createdAt: new Date(now).toISOString(), + }, + }); + } + + private cancelIdleCompaction(): void { + if (!this.idleCompactionTimer) return; + clearTimeout(this.idleCompactionTimer); + this.idleCompactionTimer = undefined; + } + + /** + * Real work outranks the background pass: a prompt cancels the pending + * timer and stands a running pass down. The abort reuses the Stop path, so + * the pass reports as an aborted idle pass (`TURN_ABORTED`, still silent) + * instead of a failure, and the wait is bounded by the summary request's + * own abort handling. The pass is best-effort: the next prompt re-checks + * the budget inline, and a later idle window re-arms it. + */ + private async yieldIdleCompaction(): Promise { + this.cancelIdleCompaction(); + const run = this.idleCompactionRun; + if (!run) return; + this.idleCompactionYielded = true; + if (this.compactionInProgress) this.compactionAborted = true; + this.compactionAbort?.abort(); + await run; + } + + private async runIdleCompaction(): Promise { + if ( + this.idleCompactionYielded || + this.disposed || + this.agent.state.isStreaming || + this.compactionInProgress + ) { + return; + } + const budget = this.contextBudget( + this.narrowToolResultsUnderPressure(this.agent.state.messages), + ); + if (budget.tokens < budget.hardLimit * this.idleCompaction.occupancyRatio) { + return; + } + // A prompt that arrived while the budget was being re-checked still gets + // the model: the pass stands down before it spends its summary request. + if (this.idleCompactionYielded) return; + this.idleCompactionActive = true; + try { + // Reason "threshold" is the truth: the same automatic protection, + // moved to a moment the user is not waiting. The idle flag on the + // events lets the renderer settle the running state the way a manual + // compaction does (clear running, flush queued prompts). + await this.runCompaction("threshold", false); + } finally { + this.idleCompactionActive = false; + } + } + private retainedUserMessageBudget(budget: ContextBudget): number { return retainedUserMessageBudget(budget); } @@ -6047,15 +6438,6 @@ Delegation rules: remaining: number, budget: ContextBudget, ): string | undefined { - if ( - remaining <= CONTEXT_FALLBACK_REMINDER_TOKENS && - !this.contextFallbackReminderClaimed - ) { - this.contextFallbackReminderClaimed = true; - // The first tier is pointless once the second has fired. - this.contextReminderClaimed = true; - return contextFallbackReminder(); - } const threshold = Math.min( CONTEXT_REMINDER_MAX_TOKENS, Math.max( @@ -6125,6 +6507,10 @@ Delegation rules: this.emit({ type: "compaction_end", reason, + ...(this.idleCompactionActive ? { idle: true } : {}), + ...(this.idleCompactionActive && this.idleCompaction.silent + ? { silent: true } + : {}), ok: false, ...(tokensBefore !== undefined ? { tokensBefore } : {}), willRetry: false, @@ -6178,15 +6564,92 @@ Delegation rules: return retainedReasoning.length > 0 ? { retainedReasoning } : {}; } + /** + * The mechanical session ledger: a bounded record of the boundary + * range's work, extracted from the preparation without spending model + * tokens. Files and commands are scanned from the range's own tool calls: + * upstream's fileOps scan only knows lowercase read/write/edit with a + * `path` argument, so it never matches this app's Read/Write/Edit tools + * (Read takes `file_path`) and seeding from it would persist empty lists + * on every checkpoint. Counts cover the summarized range. Stored on the + * checkpoint's opaque `details` and projected back into the summary by + * session-context, so the next window keeps an account of everything that + * is one recall away. + */ + private buildLedger( + preparation: ShapedPreparation, + ): SessionLedger { + const messages = [ + ...preparation.messagesToSummarize, + ...preparation.turnPrefixMessages, + ]; + const commands: string[] = []; + const filesRead = new Set(); + const filesModified = new Set(); + let toolCalls = 0; + for (const message of messages) { + if (message.role !== "assistant") continue; + const content = message.content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!isRecord(part) || part.type !== "toolCall") continue; + toolCalls += 1; + if (!isRecord(part.arguments)) continue; + const args = part.arguments; + if (part.name === "Bash" && typeof args.command === "string") { + const command = args.command; + if (command && commands.length < LEDGER_MAX_COMMANDS) { + commands.push( + command.length > LEDGER_MAX_COMMAND_CHARS + ? `${command.slice(0, LEDGER_MAX_COMMAND_CHARS)}…` + : command, + ); + } + } + const path = + typeof args.path === "string" + ? args.path + : typeof args.file_path === "string" + ? args.file_path + : undefined; + if (!path) continue; + if (part.name === "Read") filesRead.add(path); + else if (part.name === "Write" || part.name === "Edit") + filesModified.add(path); + } + } + // I (§4.I): the two re-anchor facts, extracted from the same range by the + // deterministic module the trajectory layer uses, so the ledger and a + // recovered checkpoint describe the work the same way. + const goal = extractGoal(messages); + const openItems = extractOpenItems(messages).slice(0, LEDGER_MAX_OPEN_ITEMS); + return { + filesRead: [...filesRead].sort().slice(0, LEDGER_MAX_FILES), + filesModified: [...filesModified].sort().slice(0, LEDGER_MAX_FILES), + commands, + messages: messages.length, + toolCalls, + ...(goal + ? { + goal: + goal.length > LEDGER_MAX_GOAL_CHARS + ? `${goal.slice(0, LEDGER_MAX_GOAL_CHARS)}…` + : goal, + } + : {}), + ...(openItems.length > 0 ? { openItems } : {}), + }; + } + private checkpointDetails(preparation: ShapedPreparation) { + // The same self-scanned lists the ledger uses: upstream's fileOps scan + // never matches this app's tool names, so seeding from it would persist + // empty lists on every checkpoint. + const ledger = this.buildLedger(preparation); return { - readFiles: [...preparation.fileOps.read].sort(), - modifiedFiles: [ - ...new Set([ - ...preparation.fileOps.written, - ...preparation.fileOps.edited, - ]), - ].sort(), + readFiles: ledger.filesRead, + modifiedFiles: ledger.filesModified, + ledger, }; } @@ -6260,19 +6723,10 @@ Delegation rules: // — model switch, restart — the session restores as if it had just started. // Fall back to the newest user messages under the same budget so the // failure path still restores a bounded, non-empty context (#224). - const retainedTail = - preparation.retainedTail.length > 0 - ? preparation.retainedTail - : selectRetainedUserMessages( - preparation.messagesToSummarize.filter( - (message): message is UserMessage => message.role === "user", - ), - preparation.settings.keepRecentTokens, - ); return this.createCheckpoint( { ...preparation, - retainedTail, + retainedTail: this.retainedTailForDegradedCheckpoint(preparation), }, throughMessageId, summary, @@ -6287,6 +6741,89 @@ Delegation rules: ); } + /** + * The deterministic layer between a failed model summary and the + * retained-tail notice. It costs no request and cannot fail on a provider, + * so trying it first is free; when it does not fit the safe budget either, + * the notice above still runs and stays the last resort. + */ + private createTrajectoryCheckpoint( + preparation: ShapedPreparation, + throughMessageId: string, + maxSummaryChars: number, + retentionMode: CompactionRetentionMode, + failureMessage: string, + ): ContextCompactionRecord { + const trajectory = buildTrajectorySummary({ + messages: [ + ...preparation.messagesToSummarize, + ...preparation.turnPrefixMessages, + ], + previousSummary: preparation.previousSummary, + maxPreviousChars: Math.min(TRAJECTORY_MAX_PREVIOUS_CHARS, maxSummaryChars), + }); + const continuation = + retentionMode === "active_turn" + ? "The provider is continuing the active turn. Use the one retained latest user request as the source of truth for that continuation." + : "The previous turn is complete. Treat this summary as historical context; the next user message is the only new task to execute."; + // Same layout as the retained-tail fallback: the carried summary first, the + // marker, then what this layer has to say, so a chained compaction recovers + // exactly the carried history and drops this range's description instead of + // cementing it (#224). + const summary = [ + trajectory.carried ?? COMPACTION_FALLBACK_NO_SUMMARY, + COMPACTION_FALLBACK_MARKER, + "The automatic summary request did not complete. Older messages before this checkpoint are omitted from the next model request.", + `${continuation} The deterministic record below describes what they contained.`, + trajectory.sections, + ].join("\n\n"); + return this.createCheckpoint( + { + ...preparation, + retainedTail: this.retainedTailForDegradedCheckpoint(preparation), + }, + throughMessageId, + summary, + undefined, + { + ...this.checkpointDetails(preparation), + ...this.retainedReasoningForCheckpoint(preparation), + // `trajectory` is an outcome of the degradation chain, not a policy a + // user can select, so it stays out of `CompactionStrategy` — the + // shared reader already treats anything but `fresh_window` as + // summarized, and this is a real summary, just not a model's. + strategy: "trajectory", + retainedTailMode: retentionMode, + failureCode: "CONTEXT_COMPACTION_FAILED", + failureMessage: boundedText( + failureMessage, + COMPACTION_FALLBACK_MAX_SUMMARY_CHARS, + ), + trajectory: { + messages: trajectory.stats.messages, + toolCalls: trajectory.stats.toolCalls, + failedToolCalls: trajectory.stats.failedToolCalls, + openItems: trajectory.openItems.length, + nextSteps: trajectory.nextSteps.length, + goal: trajectory.goal !== undefined, + }, + }, + ); + } + + private retainedTailForDegradedCheckpoint( + preparation: ShapedPreparation, + ): AgentMessage[] { + return preparation.retainedTail.length > 0 + ? preparation.retainedTail + : selectRetainedUserMessages( + preparation.messagesToSummarize.filter( + (message): message is UserMessage => message.role === "user", + ), + preparation.settings.keepRecentTokens, + ); + } + /** * The recovery path retains less than a normal checkpoint: its summary is a * carried-forward one rather than a fresh one, so the retained messages are @@ -6373,6 +6910,19 @@ Delegation rules: ) { return "oversized"; } + + // The estimate of the compacted projection is smaller than what the next + // request will actually carry: it is built from message content alone, while + // the request also pays for the system prompt and the tool schemas. + // `tokensBefore` is a measured request size, so the gap between it and the + // pre-compaction estimate is that overhead — adding it back keeps this + // number comparable with the request-based occupancy shown elsewhere. + const preCompactionTokens = this.contextBudget( + this.liveSessionContext().messages, + ).tokens; + checkpoint.tokensAfter = + compactedBudget.tokens + + Math.max(0, checkpoint.tokensBefore - preCompactionTokens); try { await this.host.call("session.appendCompaction", { sessionId: this.sessionId, @@ -6388,11 +6938,14 @@ Delegation rules: // A new window means both reminders are available again, matching Codex // resetting its `claim_*` flags when the context window turns over. this.contextReminderClaimed = false; - this.contextFallbackReminderClaimed = false; this.setAgentMessages(this.liveSessionContext().messages); this.emit({ type: "compaction_end", reason, + ...(this.idleCompactionActive ? { idle: true } : {}), + ...(this.idleCompactionActive && this.idleCompaction.silent + ? { silent: true } + : {}), ok: true, tokensBefore: checkpoint.tokensBefore, firstKeptMessageId: checkpoint.firstKeptMessageId, @@ -6476,17 +7029,39 @@ Delegation rules: ); return false; } + const maxSummaryChars = Math.max( + 256, + Math.min( + COMPACTION_FALLBACK_MAX_SUMMARY_CHARS, + Math.floor(budget.hardLimit * 0.75), + ), + ); + // The deterministic record gets its turn before the retained-tail notice: + // it costs no request, so trying it first is free, and it carries what a + // next window cannot reconstruct (the goal verbatim, the failures nothing + // repaired, the next step the last assistant message stated) even though + // every model attempt failed. When it does not fit the safe budget either, + // the notice below still runs and stays the last resort. + const degraded = this.createTrajectoryCheckpoint( + fallbackPreparation, + throughMessageId, + maxSummaryChars, + retentionMode, + failureMessage, + ); + const degradedPersisted = await this.persistCheckpoint( + degraded, + reason, + willRetry, + true, + "retained_tail", + ); + if (degradedPersisted === "persisted") return true; const checkpoint = this.createFallbackCheckpoint( fallbackPreparation, throughMessageId, - Math.max( - 256, - Math.min( - COMPACTION_FALLBACK_MAX_SUMMARY_CHARS, - Math.floor(budget.hardLimit * 0.75), - ), - ), + maxSummaryChars, retentionMode, ); const persisted = await this.persistCheckpoint( @@ -7326,6 +7901,9 @@ Delegation rules: type: "agent_end", messageIds: [], }); + // The run has settled: this is the only place an idle pass may arm, so + // it can never spend a summary request while the user is waiting. + this.scheduleIdleCompaction(); break; default: break; @@ -7536,6 +8114,10 @@ Delegation rules: durableTurnId?: string, ): Promise<{ turnId: string }> { if (this.disposed) throw new Error("runtime disposed"); + // A pending idle pre-compaction must never race the prompt it was waiting + // behind, and a running one must not delay the model: cancel the timer or + // stand the pass down, before the busy check. + await this.yieldIdleCompaction(); this.assertNotRunning(); const modelInput = typeof input !== "string" && input.sessionMessage ? { ...input, text: formatSessionMessage(input.text, input.sessionMessage), sessionMessage: undefined } diff --git a/packages/agent-runtime/src/session-context.test.ts b/packages/agent-runtime/src/session-context.test.ts index 3fd3522f22..5cc6378869 100644 --- a/packages/agent-runtime/src/session-context.test.ts +++ b/packages/agent-runtime/src/session-context.test.ts @@ -162,4 +162,47 @@ describe("buildSessionContext", () => { expect(JSON.stringify(messages)).not.toContain("prior DeepSeek plan"); }); + + it("carries the recall pointer and the session ledger into the summary", () => { + // Both halves of ADR 0300 ride on the projected summary: the pointer says + // pre-boundary messages are still readable, and the ledger says what the + // boundary covered. The pointer is a promise, so it is asserted together + // with the ledger that makes it useful. + const entry = { + ...compaction("c1", 5), + details: { + ledger: { + filesRead: ["src/app.ts"], + filesModified: ["src/app.ts"], + commands: ["pnpm test"], + messages: 12, + toolCalls: 4, + goal: "keep the retry budget honest", + openItems: ["the flaky test"], + }, + }, + } as CompactionEntry; + const { messages } = buildSessionContext([user("u1", "hi", 1), entry]); + const flat = JSON.stringify(messages); + + expect(flat).toContain("retrievable verbatim via the recall tool"); + expect(flat).toContain("session ledger"); + expect(flat).toContain("goal: keep the retry budget honest"); + expect(flat).toContain("files read: src/app.ts"); + expect(flat).toContain("commands: `pnpm test`"); + expect(flat).toContain("unresolved: the flaky test"); + expect(flat).toContain("12 messages, 4 tool calls"); + }); + + it("projects the pointer alone for a checkpoint that carries no ledger", () => { + // A checkpoint written before the ledger existed must not grow an empty + // block: the pointer alone is what it always projected. + const { messages } = buildSessionContext([ + user("u1", "hi", 1), + compaction("c1", 5), + ]); + const flat = JSON.stringify(messages); + expect(flat).toContain("retrievable verbatim via the recall tool"); + expect(flat).not.toContain("session ledger"); + }); }); diff --git a/packages/agent-runtime/src/session-context.ts b/packages/agent-runtime/src/session-context.ts index 5aae7c8dea..4b4c26acc2 100644 --- a/packages/agent-runtime/src/session-context.ts +++ b/packages/agent-runtime/src/session-context.ts @@ -30,6 +30,73 @@ import { * runtime never appends one live, a restored transcript drops them, and a * provider would reject or silently skip it (D446). */ +/** + * Recall pointer (ADR 0300): the stored summary never changes; the + * projection appends a note that pre-boundary messages are not deleted but + * remain in the session's transcript, retrievable verbatim via the recall tool. + * The wording is a promise, so it is only appended because `recall` exists. + */ +const RECALL_POINTER = + "\n\n[recall] Messages before this summary boundary are not deleted: they remain in this session's transcript and are retrievable verbatim via the recall tool."; + +/** + * Session ledger (ADR 0300): a mechanical, model-free record of the work a + * compaction boundary covers. It is stored on the checkpoint's opaque + * `details` (host-persisted verbatim, re-derived on restart) and projected into + * the summary message as a bounded block, so the next context window keeps an + * at-a-glance account of files, commands, the goal and what is still broken, + * whose verbatim text is one recall away. + */ +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** Open items a projected ledger names; a checkpoint stores at most five. */ +const LEDGER_BLOCK_MAX_OPEN_ITEMS = 5; + +/** A one-line block cannot carry the goal's own line breaks. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function ledgerBlock(details: unknown): string { + const ledger = (details as { ledger?: unknown } | null | undefined)?.ledger; + if (typeof ledger !== "object" || ledger === null) return ""; + const l = ledger as Record; + const filesRead = stringArray(l.filesRead).slice(0, 40); + const filesModified = stringArray(l.filesModified).slice(0, 40); + const commands = stringArray(l.commands).slice(0, 20); + const messages = typeof l.messages === "number" ? l.messages : null; + const toolCalls = typeof l.toolCalls === "number" ? l.toolCalls : null; + // The re-anchor half: where the work happened was never the question a next + // window got wrong; what it was doing and what is still broken is. Both are + // optional, so a checkpoint written before this change projects exactly the + // block it always did. + const goal = + typeof l.goal === "string" && l.goal.trim().length > 0 + ? oneLine(l.goal) + : null; + const openItems = stringArray(l.openItems).slice(0, LEDGER_BLOCK_MAX_OPEN_ITEMS); + const lines = [ + "\n\n[session ledger — mechanical record of the work before this boundary; verbatim text is retrievable via the recall tool]", + ]; + if (goal) lines.push(`goal: ${goal}`); + if (filesRead.length > 0) lines.push(`files read: ${filesRead.join(", ")}`); + if (filesModified.length > 0) + lines.push(`files modified: ${filesModified.join(", ")}`); + if (commands.length > 0) + lines.push(`commands: ${commands.map((c) => `\`${c}\``).join(", ")}`); + if (openItems.length > 0) + lines.push(`unresolved: ${openItems.map(oneLine).join(" | ")}`); + if (messages !== null || toolCalls !== null) + lines.push( + `totals: ${messages ?? "?"} messages, ${toolCalls ?? "?"} tool calls in the compacted range`, + ); + return lines.length > 1 ? lines.join("\n") : ""; +} + function isContextMessage(message: AgentMessage): boolean { return ( message.role !== "assistant" || @@ -60,7 +127,7 @@ export function sessionEntryToContextMessages( case "compaction": return [ createCompactionSummaryMessage( - entry.summary, + entry.summary + RECALL_POINTER + ledgerBlock(entry.details), entry.tokensBefore, entry.timestamp, ), diff --git a/packages/agent-runtime/src/tool-result-tier.test.ts b/packages/agent-runtime/src/tool-result-tier.test.ts new file mode 100644 index 0000000000..01a26a7f9b --- /dev/null +++ b/packages/agent-runtime/src/tool-result-tier.test.ts @@ -0,0 +1,402 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { describe, expect, it } from "vitest"; +import { + lastReadLine, + narrowToolResults, + spillPath, + TOOL_RESULT_TIER_CLEAR_AT_LEAST_CHARS, + TOOL_RESULT_TIER_EXCLUDE_TOOLS, + TOOL_RESULT_TIER_HEAD_CHARS, + TOOL_RESULT_TIER_KEEP_MARKERS, + TOOL_RESULT_TIER_KEEP_RECENT, + TOOL_RESULT_TIER_MIN_CHARS, + workingSetPathsFrom, +} from "./tool-result-tier.js"; + +const user = (text: string): AgentMessage => + ({ role: "user", content: text, timestamp: 1 }) as unknown as AgentMessage; + +const assistant = (text: string): AgentMessage => + ({ + role: "assistant", + content: [{ type: "text", text }], + provider: "p", + model: "m", + stopReason: "stop", + }) as unknown as AgentMessage; + +/** An assistant tool call in the shape the stored messages use. */ +const toolCall = ( + id: string, + name: string, + args: Record, + container: "arguments" | "args" = "arguments", +): AgentMessage => + ({ + role: "assistant", + content: [{ type: "toolCall", id, name, [container]: args }], + provider: "p", + model: "m", + stopReason: "toolUse", + }) as unknown as AgentMessage; + +const toolResult = ( + text: string, + extra: Record = {}, +): AgentMessage => + ({ + role: "toolResult", + toolCallId: "call-1", + toolName: "Read", + content: [{ type: "text", text }], + isError: false, + timestamp: 2, + ...extra, + }) as unknown as AgentMessage; + +/** A `Read` payload the way host-core renders one: header, then `N: line`. */ +function readWindow(lines: number, lineChars = 200): string { + const body = Array.from( + { length: lines }, + (_value, index) => `${index + 1}:${"x".repeat(lineChars)}`, + ).join("\n"); + return `[src/app.ts#a1b2]\n${body}`; +} + +/** A shell result whose output was spilled by the host's truncation marker. */ +function spilledShell(lines: number): string { + const body = Array.from({ length: lines }, () => "y".repeat(200)).join("\n"); + return ( + `${body}\n` + + "[truncated: kept the first 4000 of 51234 lines; limit 4000 lines / 96KB. " + + "Full output saved to C:\\data\\scratch\\s1\\tool-output\\bash-1-1.log — " + + "Grep it, or Read it with offset/limit.]" + ); +} + +const big = (chars: number): string => "x".repeat(chars); + +/** Old oversized result first, then the protected recent ones. */ +function oldReadWithRecentTail( + lines = 200, + extra: Record = {}, +): AgentMessage[] { + return [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(lines), extra), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; +} + +const textAt = (list: AgentMessage[], index: number): string => { + const content = (list[index] as unknown as { content: unknown }).content; + if (typeof content === "string") return content; + return (content as Array<{ text: string }>)[0].text; +}; + +describe("recovery pointers", () => { + it("reads the last file line a kept head shows, ignoring digits inside a line", () => { + expect(lastReadLine("[p#a1b2]\n1:a\n2:b\n3:c")).toBe(3); + // A file line that itself begins with digits and a colon: only the + // rendered prefix counts, so the answer is still the row number. + expect(lastReadLine("[p#a1b2]\n41:9: inner\n42:x")).toBe(42); + // No numbered row at all: there is nothing to continue from. + expect(lastReadLine("[p#a1b2]\nbody")).toBeUndefined(); + }); + + it("finds the spill path in the host's own truncation sentence", () => { + expect( + spillPath( + "tail\n[truncated: kept the last 10 of 99 lines; limit 4000 lines / 96KB. " + + "Full output saved to /tmp/scratch/s1/tool-output/bash-1-1.log — Grep it, or Read it with offset/limit.]", + ), + ).toBe("/tmp/scratch/s1/tool-output/bash-1-1.log"); + // No spill, so no recovery path to name. + expect(spillPath("[truncated: kept the first 10 of 99 lines; limit 4000 lines / 96KB. Narrow the request to see more.]")).toBeUndefined(); + expect(spillPath("ordinary output")).toBeUndefined(); + }); +}); + +describe("narrowToolResults", () => { + it("shortens an old Read result and points at the next line of that file", () => { + const messages = oldReadWithRecentTail(); + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(1); + const narrowed = textAt(result.messages, 1); + expect(narrowed.length).toBeLessThan(TOOL_RESULT_TIER_HEAD_CHARS + 400); + expect(narrowed).toContain("[tool result narrowed:"); + expect(narrowed).toContain('Continue with Read path="src/app.ts" offset='); + // The pointer names a real continuation: the head ends at row N, so the + // reader resumes at N + 1. + const lastLine = lastReadLine(narrowed); + expect(lastLine).toBeDefined(); + expect(narrowed).toContain(`offset=${(lastLine ?? 0) + 1}`); + // Untouched rows come back by reference, so nothing else was rewritten. + expect(result.messages[0]).toBe(messages[0]); + expect(result.messages[2]).toBe(messages[2]); + }); + + it("shortens a spilled shell result and points at the spill file", () => { + const messages = [ + toolCall("call-1", "Bash", { command: "npm test" }), + toolResult(spilledShell(200), { toolName: "Bash" }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50), { toolName: "Bash" }), + ), + ]; + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(1); + const narrowed = textAt(result.messages, 1); + expect(narrowed).toContain("[tool result narrowed:"); + expect(narrowed).toContain( + "C:\\data\\scratch\\s1\\tool-output\\bash-1-1.log", + ); + expect(narrowed).toContain("Read it with offset/limit, or Grep it"); + }); + + it("leaves a result whole when no recovery path can be named", () => { + const messages = [ + // A Grep result: re-running it is not guaranteed to return the same + // lines, so the pointer would not be exact. + toolCall("call-1", "Grep", { pattern: "needle" }), + toolResult(big(20_000), { toolName: "Grep" }), + // A shell result that was never spilled: nothing holds a fuller copy. + toolCall("call-2", "Bash", { command: "ls -R" }), + toolResult(big(20_000), { toolName: "Bash", toolCallId: "call-2" }), + // A Read result whose call named no path. + toolCall("call-3", "Read", {}), + toolResult(readWindow(200), { toolCallId: "call-3" }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(0); + expect(result.savedChars).toBe(0); + expect(result.messages).toBe(messages); + }); + + it("leaves a result with more than one text block whole", () => { + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + { + role: "toolResult", + toolCallId: "call-1", + toolName: "Read", + content: [ + { type: "text", text: readWindow(100) }, + { type: "text", text: readWindow(100) }, + ], + timestamp: 2, + } as unknown as AgentMessage, + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(0); + expect(result.messages).toBe(messages); + }); + + it("never shortens user messages, assistant prose, or small results", () => { + const messages = [ + user(big(20_000)), + assistant(big(20_000)), + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(3)), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(0); + expect(result.messages).toBe(messages); + }); + + it("keeps tool-search activation evidence intact", () => { + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(200), { addedToolNames: ["Read"] }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + expect(narrowToolResults(messages).narrowed).toBe(0); + }); + + it("never shortens a result whose text carries a keep marker", () => { + expect(TOOL_RESULT_TIER_KEEP_MARKERS).toContain("[keep]"); + const body = `${readWindow(200)}\n[KEEP]`; + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(body), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + expect(narrowToolResults(messages).narrowed).toBe(0); + }); + + it("never shortens results of excluded tools, blind to a missing tool name", () => { + expect(TOOL_RESULT_TIER_EXCLUDE_TOOLS).toContain("TaskWait"); + const excluded = [ + toolCall("call-1", "TaskWait", {}), + toolResult(readWindow(200), { toolName: "TaskWait" }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + expect(narrowToolResults(excluded).narrowed).toBe(0); + + // Without a tool name the exclusion cannot match, but the pass then has no + // recovery path either, so the result still comes back whole. + const unnamed = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(200), { toolName: undefined }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + expect(narrowToolResults(unnamed).narrowed).toBe(0); + }); + + it("keeps working-set results whole and narrows the rest", () => { + const messages = [ + toolCall("call-1", "Read", { file_path: "src/in-play.ts" }), + toolResult(readWindow(200), { toolCallId: "call-1" }), + toolCall("call-2", "Read", { file_path: "src/old.ts" }), + toolResult(readWindow(200), { toolCallId: "call-2" }), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages, { + workingSetPaths: ["src/in-play.ts"], + }); + + expect(result.narrowed).toBe(1); + expect(textAt(result.messages, 1)).toBe(readWindow(200)); + expect(textAt(result.messages, 3)).toContain("[tool result narrowed:"); + }); + + it("works from the paths the batch's own calls named", () => { + const messages = [ + toolCall("a", "Read", { file_path: "one.ts" }, "args"), + toolCall("b", "Read", { path: "two.ts/" }), + toolCall("c", "Edit", { filePath: "C:\\work\\three.ts" }), + ]; + expect(workingSetPathsFrom(messages)).toEqual([ + "one.ts", + "two.ts", + "C:/work/three.ts", + ]); + }); + + it("returns the original array when the saving misses clearAtLeastChars", () => { + expect(TOOL_RESULT_TIER_CLEAR_AT_LEAST_CHARS).toBe(8_000); + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + // Over minChars, but narrowing it saves less than the floor. + toolResult(readWindow(25, 200)), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages); + + expect(result.narrowed).toBe(0); + expect(result.savedChars).toBe(0); + expect(result.messages).toBe(messages); + }); + + it("reports the characters a pass saved, pointer included", () => { + const messages = oldReadWithRecentTail(); + const before = textAt(messages, 1).length; + const result = narrowToolResults(messages); + + expect(result.savedChars).toBeGreaterThanOrEqual( + TOOL_RESULT_TIER_CLEAR_AT_LEAST_CHARS, + ); + expect(result.savedChars).toBe(before - textAt(result.messages, 1).length); + }); + + it("is deterministic and idempotent", () => { + const first = narrowToolResults(oldReadWithRecentTail()); + const second = narrowToolResults(oldReadWithRecentTail()); + expect(textAt(second.messages, 1)).toBe(textAt(first.messages, 1)); + + // A second pass over an already-narrowed view is a no-op: the head is below + // minChars, so nothing else moves. + const again = narrowToolResults(first.messages); + expect(again.narrowed).toBe(0); + }); + + it("returns the same array when there are no tool results at all", () => { + const messages = [user("hi"), assistant("hello")]; + const result = narrowToolResults(messages); + expect(result.messages).toBe(messages); + expect(result.narrowed).toBe(0); + expect(result.savedChars).toBe(0); + }); + + it("appends the pointer once, at the end, when content is a string", () => { + const body = readWindow(200); + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + { + role: "toolResult", + toolCallId: "call-1", + toolName: "Read", + content: body, + timestamp: 2, + } as unknown as AgentMessage, + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(messages); + const narrowed = textAt(result.messages, 1); + + expect(narrowed.endsWith("]")).toBe(true); + expect(narrowed.split("[tool result narrowed:").length - 1).toBe(1); + expect(narrowed.startsWith(body.slice(0, 100))).toBe(true); + }); + + it("uses the documented defaults when no option is passed", () => { + expect(TOOL_RESULT_TIER_MIN_CHARS).toBe(4_000); + expect(TOOL_RESULT_TIER_HEAD_CHARS).toBe(1_200); + expect(TOOL_RESULT_TIER_KEEP_RECENT).toBe(6); + + // A result just over the default minimum, with nothing keeping it: still a + // no-op below the clear-at-least floor. + const messages = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(25, 200)), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + expect(narrowToolResults(messages).narrowed).toBe(0); + + // The same shape, larger: it narrows, and only the oldest result moved. + const large = [ + toolCall("call-1", "Read", { file_path: "src/app.ts" }), + toolResult(readWindow(400, 200)), + ...Array.from({ length: TOOL_RESULT_TIER_KEEP_RECENT }, () => + toolResult(big(50)), + ), + ]; + const result = narrowToolResults(large); + expect(result.narrowed).toBe(1); + for (let index = 2; index < large.length; index += 1) { + expect(result.messages[index]).toBe(large[index]); + } + }); +}); diff --git a/packages/agent-runtime/src/tool-result-tier.ts b/packages/agent-runtime/src/tool-result-tier.ts new file mode 100644 index 0000000000..98af05b858 --- /dev/null +++ b/packages/agent-runtime/src/tool-result-tier.ts @@ -0,0 +1,508 @@ +/** + * Tool-result tiering for the outgoing view. + * + * Tool results are most of the context mass. Under pressure an old tool result + * is the cheapest thing to shorten: its text is not lost — a file-backed result + * can be read again, and a spilled shell result is still on disk — so shortening + * it removes low-density weight without creating a gap the reader cannot close. + * Paying for a lossy summary pass to recover the same room is the expensive + * alternative. + * + * This complements the per-result budgets in + * `docs/spec/03-runtime/16-tool-result-limits.md` rather than replacing them. + * Those bound **one** result when it is produced (Read 128 KB, shell 96 KB, plus + * spill files); this pass bounds the **aggregate** an outgoing request carries, + * which several individually-legal results can still exceed. A 128 KB Read + * window narrowed to a head plus a pointer is exactly the mass that matters + * here. + * + * The pass is deliberately narrow: + * + * - **Only tool results.** User messages, assistant prose and the compaction + * summary are never touched: they are synthesis or intent, and there is no + * way to fetch them again. + * - **Only the outgoing view.** Callers pass the message list a provider request + * is about to send; stored messages are never modified, and the projection is + * rebuilt for every request, so a later request with room renders the full + * text again. + * - **Deterministic.** Fixed size and count thresholds. No model call, no + * scoring, no per-session state. Every decision reads only the batch handed in. + * - **A recovery path or nothing.** A result is narrowed only when this module + * can name a real way to get the rest back, and the pointer names it exactly: + * a file-backed `Read` result points at the next line of that file, and a + * shell result whose output was spilled points at the spill file. Anything + * else — a `Grep`, a `Glob`, a shell result that was not spilled — is left + * whole, because a pointer the reader cannot act on is worse than a long + * result. That is also why `Grep`/`Glob` are not narrowed: re-running them is + * not guaranteed to return the same lines, so the pointer would not be exact. + * - **A no-op by default.** The caller decides when to run it (see + * `TOOL_RESULT_TIER_PRESSURE`); when nothing qualifies, or when the saving is + * below the floor, the same array comes back. + * + * Four precision knobs narrow the pass further. Each defaults to the documented + * value: + * + * - `keepMarkers` — a result whose text contains any marker is never shortened, + * so a user-pasted error or a result the model flagged to keep stays whole. + * Matched with a **case-insensitive plain substring**, never a regex: result + * text is user content and its metacharacters must not be interpreted. + * - `excludeTools` — results of these tools are never shortened. Subagent + * reports and retrieval hits are the most expensive information in the window; + * shortening them throws away what was just fetched. Matched by **exact, + * case-sensitive tool name**. + * - `workingSetPaths` — a result whose tool call named one of these files stays + * whole: a file the session is still working in is still in play. The + * `toolCallId → path` map is built from the `toolCall` blocks of the **same + * batch**, never from stored messages. Paths compare after normalizing + * trailing slashes, path separators and the Windows drive-letter case — no + * `realpath`, no filesystem access. + * - `clearAtLeastChars` — the pass is a no-op unless its summed saving reaches + * this floor. Rewriting the outgoing view for a few hundred characters is a + * bad trade: it breaks the provider's prompt cache for everything after the + * rewrite. + * +/** + * Fraction of the hard context limit at which the caller should start tiering. + * Below the compaction threshold on purpose: the point is to drop back under it + * with cheap, recoverable evidence instead of paying for a lossy summary pass. + */ +export const TOOL_RESULT_TIER_PRESSURE = 0.6; + +/** Results at or below this size are not worth a pointer. */ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +/** Results at or below this size are not worth a pointer. */ +export const TOOL_RESULT_TIER_MIN_CHARS = 4_000; + +/** How much of a narrowed result stays inline. */ +export const TOOL_RESULT_TIER_HEAD_CHARS = 1_200; + +/** The newest results are almost certainly still in play; leave them whole. */ +export const TOOL_RESULT_TIER_KEEP_RECENT = 6; + +/** A result whose text contains any of these markers is never narrowed. */ +export const TOOL_RESULT_TIER_KEEP_MARKERS = ["[keep]"]; + +/** Results of these tools are never narrowed: they are the expensive parts. */ +export const TOOL_RESULT_TIER_EXCLUDE_TOOLS = [ + "Task", + "TaskWait", + "TaskList", + "TaskStop", + "ToolSearch", +]; + +/** + * The pass is a no-op unless it saves at least this many characters — a small + * rewrite is not worth breaking the provider's prompt cache. + */ +export const TOOL_RESULT_TIER_CLEAR_AT_LEAST_CHARS = 8_000; + +export type ToolResultTierOptions = { + minChars?: number; + headChars?: number; + keepRecent?: number; + /** Markers that immunize a result from narrowing (case-insensitive). */ + keepMarkers?: string[]; + /** Tool names whose results are never narrowed. */ + excludeTools?: string[]; + /** File paths whose tool results stay whole. Empty or absent means none. */ + workingSetPaths?: string[]; + /** Minimum summed saving for the pass to apply at all. */ + clearAtLeastChars?: number; +}; + +export type ToolResultTierResult = { + messages: AgentMessage[]; + /** How many results were shortened (0 means the same array came back). */ + narrowed: number; + /** Characters this pass removed, pointer text included; 0 when it no-opped. */ + savedChars: number; +}; + +type TextBlock = { type?: unknown; text?: unknown }; + +type ToolResultRecord = { + role: string; + content: unknown; + addedToolNames?: unknown; + toolCallId?: unknown; + toolName?: unknown; +}; + +/** Characters of text across a message's text blocks. */ +function textChars(content: unknown): number { + if (typeof content === "string") return content.length; + if (!Array.isArray(content)) return 0; + let total = 0; + for (const block of content as TextBlock[]) { + if (block && typeof block === "object" && typeof block.text === "string") { + total += block.text.length; + } + } + return total; +} + +/** The single text payload of a result, when it has exactly one. */ +function singleText(content: unknown): string | undefined { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return undefined; + const texts = (content as TextBlock[]) + .filter((block) => block && typeof block.text === "string") + .map((block) => block.text as string); + return texts.length === 1 ? texts[0] : undefined; +} + +/** + * The last file line number a `Read` window shows. + * + * The payload is `[path#tag]` followed by `N: line` rows whose `N` is the + * file's own line number, so the last one is where a continuation starts. Rows + * are scanned from the end because a file line may itself begin with digits and + * a colon; only the rendered prefix counts. + */ +export function lastReadLine(head: string): number | undefined { + const lines = head.split("\n"); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = /^\s*(\d+):/.exec(lines[index] ?? ""); + if (match) return Number(match[1]); + } + return undefined; +} + +/** The spill file a shell result's truncation marker named, when it did. */ +export function spillPath(content: string): string | undefined { + // The sentence is host-core's (`tools/mod.rs`): "Full output saved to + // — Grep it, or Read it with offset/limit." The dash and the trailing clause + // are matched loosely so a rewrite of the wording is not a silent failure. + const match = /Full output saved to ([^\n]+?)\s+(?:—|–|--)\s+Grep it/.exec( + content, + ); + const path = match?.[1]?.trim(); + return path && path.length > 0 ? path : undefined; +} + +/** A pointer to text that can still be fetched, or `undefined` when it cannot. */ +export type RecoveryPointer = { + /** The exact call that reads the rest. */ + pointer: string; + /** Set when the pointer is a line offset into `path` (a `Read` result). */ + path?: string; + offset?: number; +}; + +/** + * The recovery path for one result, or `undefined` to leave it whole. + * + * `path` is the file its tool call named, when the call named one. + */ +export function toolResultRecovery(input: { + toolName: string | undefined; + content: string; + head: string; + totalChars: number; + path: string | undefined; +}): RecoveryPointer | undefined { + const shown = input.head.length; + if (input.toolName === "Read" && input.path) { + const line = lastReadLine(input.head); + if (line === undefined) return undefined; + return { + path: input.path, + offset: line + 1, + pointer: + `[tool result narrowed: kept the first ${shown} of ${input.totalChars} characters, through line ${line}. ` + + `Continue with Read path="${input.path}" offset=${line + 1}.]`, + }; + } + const spill = spillPath(input.content); + if (spill) { + return { + pointer: + `[tool result narrowed: kept the first ${shown} of ${input.totalChars} characters. ` + + `The full captured output is at ${spill} — Read it with offset/limit, or Grep it.]`, + }; + } + return undefined; +} + +/** + * Narrow a result whose text is one string or one text block: keep its head and + * append the pointer once, at its end. A result whose text spans more than one + * block is not eligible: a continuation offset cannot be an honest pointer when + * the kept head is only part of the payload. + */ +function narrowContent( + content: unknown, + headChars: number, + pointer: string, +): { content: unknown; changed: boolean } { + if (typeof content === "string") { + if (content.length <= headChars) return { content, changed: false }; + return { + content: `${content.slice(0, headChars)}${pointer}`, + changed: true, + }; + } + if (!Array.isArray(content)) return { content, changed: false }; + const blocks = content as Array>; + const textIndexes = blocks + .map((block, index) => (block && typeof block.text === "string" ? index : -1)) + .filter((index) => index >= 0); + if (textIndexes.length !== 1) return { content, changed: false }; + const textIndex = textIndexes[0]; + let changed = false; + const next = blocks.map((block, index) => { + if (typeof block.text !== "string" || index !== textIndex) return block; + if (block.text.length <= headChars) return block; + changed = true; + return { ...block, text: `${block.text.slice(0, headChars)}${pointer}` }; + }); + return changed ? { content: next, changed: true } : { content, changed: false }; +} + +/** Argument spellings a tool call may name a file by, in precedence order. */ +const FILE_PATH_ARG_KEYS = ["file_path", "path", "filePath"] as const; + +/** + * Normalize a path for set comparison: forward slashes, no trailing slash, + * upper-case Windows drive letter. Deliberately no `realpath` — no filesystem + * access, and the same input always compares the same way. + */ +function normalizePath(path: string): string { + let normalized = path.trim().replace(/\\/g, "/"); + while (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized.replace( + /^([A-Za-z]):\//, + (_match, drive: string) => `${drive.toUpperCase()}:/`, + ); +} + +/** The first file path a tool call's argument object names, if any. */ +function pathFromArgs(args: unknown): string | undefined { + if (!args || typeof args !== "object") return undefined; + const record = args as Record; + for (const key of FILE_PATH_ARG_KEYS) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +/** + * `toolCallId` → path, read from the `toolCall` blocks of this same batch. + * Nothing here is persisted; the map exists for this pass only. + */ +function toolCallPaths(messages: AgentMessage[]): Map { + const paths = new Map(); + for (const message of messages) { + const record = message as unknown as { role?: unknown; content?: unknown }; + if (record.role !== "assistant" || !Array.isArray(record.content)) continue; + for (const block of record.content as Array>) { + if (!block || typeof block !== "object" || block.type !== "toolCall") { + continue; + } + const id = + typeof block.id === "string" + ? block.id + : typeof block.toolCallId === "string" + ? block.toolCallId + : undefined; + if (!id) continue; + // `args` is the spelling callers pass; `arguments` is what stored + // assistant messages use. Both name the same object. + const path = pathFromArgs(block.args ?? block.arguments); + if (path !== undefined) paths.set(id, normalizePath(path)); + } + } + return paths; +} + +/** + * How many of the newest file-touching tool calls define the working set. + * + * Eight, not the newest twenty: the pass already keeps the newest + * `KEEP_RECENT` results whole, so a wide working set mostly re-protects the same + * rows while making the pressure pass a no-op on small windows. + */ +export const TOOL_RESULT_TIER_WORKING_SET_TOOL_CALLS = 8; + +/** + * The working set the caller passes as `workingSetPaths`: the paths named by the + * newest `lastToolCalls` file-touching tool calls, newest call's path last. + * + * This pass keeps the newest N *results* whole, but that is a count, not a + * notion of "what the task is about right now". A result for a file the session + * has just re-opened is still in play even when its row is old. + */ +export function workingSetPathsFrom( + messages: AgentMessage[], + options: { lastToolCalls?: number } = {}, +): string[] { + const limit = Math.max( + 0, + Math.floor(options.lastToolCalls ?? TOOL_RESULT_TIER_WORKING_SET_TOOL_CALLS), + ); + if (limit === 0) return []; + const seen = new Set(); + const ordered: string[] = []; + // Walk backwards: the newest calls decide the set, and a path already taken + // keeps its newest position (an older row must not move it). + for ( + let index = messages.length - 1; + index >= 0 && seen.size < limit; + index -= 1 + ) { + const record = messages[index] as unknown as { + role?: unknown; + content?: unknown; + }; + if (record.role !== "assistant" || !Array.isArray(record.content)) continue; + const content = record.content as Array>; + for ( + let block = content.length - 1; + block >= 0 && seen.size < limit; + block -= 1 + ) { + const candidate = content[block]; + if ( + !candidate || + typeof candidate !== "object" || + candidate.type !== "toolCall" + ) { + continue; + } + const path = pathFromArgs(candidate.args ?? candidate.arguments); + if (path === undefined) continue; + const normalized = normalizePath(path); + if (seen.has(normalized)) continue; + seen.add(normalized); + ordered.push(normalized); + } + } + return ordered.reverse(); +} + +/** + * Whether any text in the content contains one of the (already lower-cased) + * markers, as a plain substring. No regex: result text is user content. + */ +function containsMarker(content: unknown, markers: string[]): boolean { + if (markers.length === 0) return false; + const texts: string[] = []; + const single = singleText(content); + if (single !== undefined) { + texts.push(single); + } else if (Array.isArray(content)) { + for (const block of content as TextBlock[]) { + if (block && typeof block === "object" && typeof block.text === "string") { + texts.push(block.text); + } + } + } + return texts.some((text) => { + const haystack = text.toLowerCase(); + return markers.some((marker) => haystack.includes(marker)); + }); +} + +/** + * Shorten old tool results in an outgoing view, naming the recovery path for + * each one. A result whose recovery path this module cannot name is skipped, + * because a pointer the reader cannot act on is worse than a long result. + * + * Returns the same array when nothing changed. + */ +export function narrowToolResults( + messages: AgentMessage[], + options: ToolResultTierOptions = {}, +): ToolResultTierResult { + const minChars = options.minChars ?? TOOL_RESULT_TIER_MIN_CHARS; + const headChars = options.headChars ?? TOOL_RESULT_TIER_HEAD_CHARS; + const keepRecent = options.keepRecent ?? TOOL_RESULT_TIER_KEEP_RECENT; + const keepMarkers = (options.keepMarkers ?? TOOL_RESULT_TIER_KEEP_MARKERS) + .filter((marker) => marker.length > 0) + .map((marker) => marker.toLowerCase()); + const excludeTools = new Set( + options.excludeTools ?? TOOL_RESULT_TIER_EXCLUDE_TOOLS, + ); + const clearAtLeastChars = + options.clearAtLeastChars ?? TOOL_RESULT_TIER_CLEAR_AT_LEAST_CHARS; + // The working set and the paths it is matched against both come from this + // batch alone; an unknown result is outside the set, never guessed at. + const workingSet = + options.workingSetPaths && options.workingSetPaths.length > 0 + ? new Set(options.workingSetPaths.map(normalizePath)) + : undefined; + const toolPaths = toolCallPaths(messages); + + // Which tool results are "recent" is decided over the whole list first, so + // the set does not depend on how many were narrowed. + const toolIndexes: number[] = []; + for (let index = 0; index < messages.length; index += 1) { + if ((messages[index] as { role?: unknown }).role === "toolResult") { + toolIndexes.push(index); + } + } + if (toolIndexes.length === 0) return { messages, narrowed: 0, savedChars: 0 }; + const protectedIndexes = new Set(toolIndexes.slice(-keepRecent)); + + let narrowed = 0; + let savedChars = 0; + const next = messages.map((message, index) => { + if (!toolIndexes.includes(index) || protectedIndexes.has(index)) { + return message; + } + const record = message as unknown as ToolResultRecord; + // Tool-search activation evidence lives on the result; shortening it would + // change what a later request may restore. + if (Array.isArray(record.addedToolNames) && record.addedToolNames.length > 0) { + return message; + } + // Subagent reports and retrieval hits are the least replaceable evidence. + if (typeof record.toolName === "string" && excludeTools.has(record.toolName)) { + return message; + } + // A file the session is still working in is still in play. + const callPath = + typeof record.toolCallId === "string" + ? toolPaths.get(record.toolCallId) + : undefined; + if (workingSet && callPath !== undefined && workingSet.has(callPath)) { + return message; + } + const totalChars = textChars(record.content); + if (totalChars <= minChars) return message; + // A user-pasted error or an explicitly preserved result stays byte whole. + if (containsMarker(record.content, keepMarkers)) return message; + const text = singleText(record.content); + if (text === undefined) return message; + const recovery = toolResultRecovery({ + toolName: typeof record.toolName === "string" ? record.toolName : undefined, + content: text, + head: text.slice(0, headChars), + totalChars, + path: callPath, + }); + if (!recovery) return message; + const narrowedContent = narrowContent( + record.content, + headChars, + recovery.pointer, + ); + if (!narrowedContent.changed) return message; + narrowed += 1; + savedChars += totalChars - textChars(narrowedContent.content); + return { ...record, content: narrowedContent.content } as unknown as AgentMessage; + }); + + if (narrowed === 0) return { messages, narrowed: 0, savedChars: 0 }; + // A rewrite costs the provider's prompt cache for every message after it; + // below the floor the trade is not worth taking, so the view stays as it was. + if (savedChars < clearAtLeastChars) { + return { messages, narrowed: 0, savedChars: 0 }; + } + return { messages: next, narrowed, savedChars }; +} diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index bf3a8c7de0..f504da1ad7 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -566,6 +566,22 @@ export const de = { "configureImageModel": "Bildmodell konfigurieren", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "Frühe Kompaktierung", +earlyCompactionWindow: "Kompaktieren ab", +earlyCompactionDelay: "Leerlaufverzögerung", +earlyCompactionSilent: "Stiller Hintergrundlauf", +earlyCompactionHint: "Sobald die Sitzung untätig ist und der Kontext diesen Anteil des Fensters erreicht hat, kompaktiert der Durchlauf im Hintergrund. Transkriptzeile, Kontextinspektor und recall zeichnen ihn weiterhin auf; beim Erreichen des harten Limits wird immer gewarnt.", +earlyCompactionReadout: "{{percent}} % (ca. {{tokens}})", +earlyCompactionDelayReadout: "nach {{seconds}} s Leerlauf", +sleepTime: "Ruhephasen-Zusammenfassung", +sleepTimeRunsPerHour: "Läufe pro Stunde", +sleepTimeReadout: "bis zu {{runs}} pro Stunde", +sleepTimeHint: "Solange die Sitzung untätig ist und noch eine Kompaktierung aussteht, fügt die Laufzeit dem Transkript eine kleine deterministische Zusammenfassung des Arbeitsstands hinzu — Ziel, blockierte Punkte, nächster Schritt —, ohne dafür ein Modell aufzurufen. Das Kontingent begrenzt, wie oft eine Zusammenfassung erstellt werden darf.", sklm: { browse: "Markt", back: "Meine Skills", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 39010342e8..cbe2d66c31 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -573,6 +573,22 @@ export const en = { configureImageModel: "Configure image model", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "Early compaction", +earlyCompactionWindow: "Start compacting at", +earlyCompactionDelay: "Idle delay", +earlyCompactionSilent: "Silent background pass", +earlyCompactionHint: "Once the session has been idle and the context has used that share of the window, the pass compacts in the background. The transcript row, the context inspector and recall still record it; reaching the hard limit always warns.", +earlyCompactionReadout: "{{percent}}% (about {{tokens}})", +earlyCompactionDelayReadout: "after {{seconds}}s idle", +sleepTime: "Sleep-time digest", +sleepTimeRunsPerHour: "Runs per hour", +sleepTimeReadout: "up to {{runs}} per hour", +sleepTimeHint: "While the session is idle and a compaction is still ahead of it, the runtime appends a small deterministic summary of where the work stands — goal, blocked items, next step — to the transcript without calling a model; the quota bounds how often a digest may be taken.", sklm: { browse: "Market", back: "My skills", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index c2f8087be8..f83ab4108b 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -566,6 +566,22 @@ export const es = { "configureImageModel": "Configurar modelo de imágenes", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "Compactación anticipada", +earlyCompactionWindow: "Compactar a partir de", +earlyCompactionDelay: "Retardo de inactividad", +earlyCompactionSilent: "Pase en segundo plano silencioso", +earlyCompactionHint: "Cuando la sesión esté inactiva y el contexto haya usado esa parte de la ventana, el pase compacta en segundo plano. La fila del historial, el inspector de contexto y recall lo siguen registrando; al alcanzar el límite estricto siempre se avisa.", +earlyCompactionReadout: "{{percent}} % (aprox. {{tokens}})", +earlyCompactionDelayReadout: "tras {{seconds}} s de inactividad", +sleepTime: "Resumen en reposo", +sleepTimeRunsPerHour: "Ejecuciones por hora", +sleepTimeReadout: "hasta {{runs}} por hora", +sleepTimeHint: "Mientras la sesión está inactiva y aún queda una compactación pendiente, el runtime añade al transcript un pequeño resumen determinista de en qué punto va el trabajo —objetivo, elementos bloqueados, siguiente paso— sin llamar a ningún modelo. La cuota limita la frecuencia con la que se puede tomar un resumen.", sklm: { browse: "Mercado", back: "Mis skills", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index db98920a83..ec7f03441f 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -566,6 +566,22 @@ export const fr = { "configureImageModel": "Configurer le modèle d’images", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "Compactage anticipé", +earlyCompactionWindow: "Compacter à partir de", +earlyCompactionDelay: "Délai d'inactivité", +earlyCompactionSilent: "Passe en arrière-plan silencieuse", +earlyCompactionHint: "Une fois la session inactive et le contexte ayant utilisé cette part de la fenêtre, la passe compacte en arrière-plan. La ligne de transcription, l'inspecteur de contexte et recall l'enregistrent toujours ; atteindre la limite stricte avertit toujours.", +earlyCompactionReadout: "{{percent}} % (environ {{tokens}})", +earlyCompactionDelayReadout: "après {{seconds}} s d'inactivité", +sleepTime: "Résumé en veille", +sleepTimeRunsPerHour: "Exécutions par heure", +sleepTimeReadout: "jusqu'à {{runs}} par heure", +sleepTimeHint: "Pendant que la session est inactive et qu'une compaction reste à venir, le runtime ajoute au transcript un petit résumé déterministe de l'état du travail — objectif, éléments bloqués, prochaine étape — sans jamais appeler de modèle. Le quota limite la fréquence à laquelle un résumé peut être produit.", sklm: { browse: "Marché", back: "Mes skills", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 45baff3d52..098fd846f8 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -575,6 +575,22 @@ export const ko = { "configureImageModel": "이미지 모델 설정", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "조기 압축", +earlyCompactionWindow: "압축 시작 시점", +earlyCompactionDelay: "유휴 지연", +earlyCompactionSilent: "조용한 백그라운드 실행", +earlyCompactionHint: "세션이 유휴 상태이고 컨텍스트가 창의 해당 비율을 사용하면 백그라운드에서 압축합니다. 대화 기록 행, 컨텍스트 검사기와 recall에는 계속 기록되며, 하드 한도에 도달하면 항상 경고합니다.", +earlyCompactionReadout: "{{percent}}% (약 {{tokens}})", +earlyCompactionDelayReadout: "{{seconds}}초 유휴 후", +sleepTime: "휴면 시간 요약", +sleepTimeRunsPerHour: "시간당 실행 횟수", +sleepTimeReadout: "시간당 최대 {{runs}}회", +sleepTimeHint: "세션이 유휴 상태이고 아직 압축이 남아 있으면 런타임은 모델을 호출하지 않고 작업 현황(목표, 막힌 항목, 다음 단계)을 담은 작은 결정적 요약을 대화 기록에 추가합니다. 할당량은 요약을 만들 수 있는 빈도를 제한합니다.", sklm: { browse: "마켓", back: "내 스킬", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index c220b08709..3f8863c0d0 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -575,6 +575,22 @@ export const tr = { "configureImageModel": "Görsel modelini yapılandır", +dynamicContext: "Dynamic context", +dynamicContextWindow: "Start narrowing at", +dynamicContextHint: "When the context reaches this share of the window, older tool results are shortened to a head plus a recall pointer; the full text stays readable with recall. Off means nothing is narrowed.", +dynamicContextPresets: "Dynamic context presets", +dynamicContextReadout: "{{percent}}% (about {{tokens}})", +earlyCompaction: "Erken sıkıştırma", +earlyCompactionWindow: "Sıkıştırmaya başla", +earlyCompactionDelay: "Boşta bekleme", +earlyCompactionSilent: "Sessiz arka plan geçişi", +earlyCompactionHint: "Oturum boştayken ve bağlam pencerenin bu oranını kullandığında geçiş arka planda sıkıştırır. Transkript satırı, bağlam denetçisi ve recall bunu yine de kaydeder; sabit sınıra ulaşıldığında her zaman uyarılır.", +earlyCompactionReadout: "{{percent}}% (yaklaşık {{tokens}})", +earlyCompactionDelayReadout: "{{seconds}} sn boşta kaldıktan sonra", +sleepTime: "Uyku zamanı özeti", +sleepTimeRunsPerHour: "Saat başına çalışma", +sleepTimeReadout: "saatte en fazla {{runs}} kez", +sleepTimeHint: "Oturum boştayken ve önünde hâlâ bir sıkıştırma varken çalışma zamanı, bir model çağırmadan işin durumunu (hedef, engellenen maddeler, sonraki adım) anlatan küçük ve belirlenimci bir özeti transkripte ekler. Kota, özetin ne sıklıkla alınabileceğini sınırlar.", sklm: { browse: "Market", back: "Skill'lerim", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 02dc5da61d..8222139877 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -570,6 +570,22 @@ export const zhCN = { configureImageModel: "配置生图模型", +dynamicContext: "动态上下文", +dynamicContextWindow: "开始收窄于", +dynamicContextHint: "上下文达到窗口该比例时,较早的工具结果会收窄为摘要头 + recall 指针,原文仍可用 recall 逐页读回;关闭则不做任何收窄。", +dynamicContextPresets: "动态上下文预设", +dynamicContextReadout: "{{percent}}%(约 {{tokens}})", +earlyCompaction: "提前压缩", +earlyCompactionWindow: "开始压缩于", +earlyCompactionDelay: "空闲延迟", +earlyCompactionSilent: "静默后台压缩", +earlyCompactionHint: "会话空闲且上下文达到窗口该比例后,会在后台执行压缩。对话记录行、上下文检查器和 recall 仍会记录这次压缩;触及硬上限时始终会警告。", +earlyCompactionReadout: "{{percent}}%(约 {{tokens}})", +earlyCompactionDelayReadout: "空闲 {{seconds}} 秒后", +sleepTime: "休眠时段摘要", +sleepTimeRunsPerHour: "每小时运行次数", +sleepTimeReadout: "每小时最多 {{runs}} 次", +sleepTimeHint: "会话空闲且前方仍有压缩待执行时,运行时会向对话记录追加一份简短的确定性摘要,说明工作进展——目标、受阻项、下一步——且不会调用模型。配额限制摘要的生成频率。", sklm: { browse: "市场", back: "我的技能", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index d6f46a1896..76ba869d1c 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -570,6 +570,22 @@ export const zhTW = { configureImageModel: "設定生圖模型", +dynamicContext: "動態上下文", +dynamicContextWindow: "開始收窄於", +dynamicContextHint: "上下文達到視窗該比例時,較早的工具結果會收窄為摘要頭 + recall 指標,原文仍可用 recall 逐頁讀回;關閉則不做任何收窄。", +dynamicContextPresets: "動態上下文預設", +dynamicContextReadout: "{{percent}}%(約 {{tokens}})", +earlyCompaction: "提前壓縮", +earlyCompactionWindow: "開始壓縮於", +earlyCompactionDelay: "閒置延遲", +earlyCompactionSilent: "靜默背景壓縮", +earlyCompactionHint: "工作階段閒置且上下文達到視窗該比例後,會在背景執行壓縮。對話記錄列、上下文檢查器與 recall 仍會記錄這次壓縮;觸及硬上限時一律會警告。", +earlyCompactionReadout: "{{percent}}%(約 {{tokens}})", +earlyCompactionDelayReadout: "閒置 {{seconds}} 秒後", +sleepTime: "休眠時段摘要", +sleepTimeRunsPerHour: "每小時執行次數", +sleepTimeReadout: "每小時最多 {{runs}} 次", +sleepTimeHint: "工作階段閒置且前方仍有壓縮待執行時,執行階段會將一份簡短的確定性摘要附加到對話記錄,說明工作進度——目標、受阻項目、下一步——且不會呼叫模型。配額限制摘要的產生頻率。", sklm: { browse: "市場", back: "我的技能", diff --git a/packages/shared/src/context-compaction.test.ts b/packages/shared/src/context-compaction.test.ts index 7935083fa0..8082d12abe 100644 --- a/packages/shared/src/context-compaction.test.ts +++ b/packages/shared/src/context-compaction.test.ts @@ -56,6 +56,16 @@ describe("contextCompactionMark", () => { }); }); + it("carries the post-compaction estimate when the record has one", () => { + // The ring leads with this number until a real request reports usage, so it + // has to survive the record → mark hop the event is built from. + expect(contextCompactionMark(record({ tokensAfter: 21_000 }))).toMatchObject({ + tokensAfter: 21_000, + }); + // A checkpoint written before the field existed must not invent one. + expect(contextCompactionMark(record())).not.toHaveProperty("tokensAfter"); + }); + it("marks a rollover checkpoint as carrying no real summary", () => { expect( contextCompactionMark(record({ details: { strategy: "fresh_window" } })) diff --git a/packages/shared/src/context-compaction.ts b/packages/shared/src/context-compaction.ts index 55c5535666..e10b201ebc 100644 --- a/packages/shared/src/context-compaction.ts +++ b/packages/shared/src/context-compaction.ts @@ -53,7 +53,10 @@ export function contextCompactionMark( throughMessageId: record.throughMessageId, generation: checkpointGeneration(record.details), summaryTokens: estimateSummaryTokens(record.summary ?? ""), - summarized: checkpointSummarized(record.details), + ...(record.tokensAfter !== undefined + ? { tokensAfter: record.tokensAfter } + : {}), ...(fallback ? { fallback } : {}), + summarized: checkpointSummarized(record.details), }; } diff --git a/packages/shared/src/types/models.ts b/packages/shared/src/types/models.ts index c91f044e13..303b69d328 100644 --- a/packages/shared/src/types/models.ts +++ b/packages/shared/src/types/models.ts @@ -122,8 +122,146 @@ export type ModelBinding = { * knowledge of the endpoint is the only source. */ nativeWebSearch?: boolean; + /** Dynamic context gate (D447): the share of the hard limit at which old tool + * results start being narrowed. Absent means the shipped behaviour; the + * runtime falls back to `defaultDynamicContextPercent(contextWindow)`. */ + dynamicContext?: ModelDynamicContext; + /** Early background compaction (ADR 0301): the share of the hard limit at + * which an idle session compacts in the background, how long it must be idle, + * and whether a successful pass is silent. Absent means the shipped defaults. */ + earlyCompaction?: ModelEarlyCompaction; + /** Sleep-time digest (ADR 0301): off unless turned on. */ + sleepTime?: ModelSleepTime; }; +export type ModelDynamicContext = { + enabled: boolean; + /** 40–95; below the floor a session starts shedding while still roomy, above + * the ceiling the narrowing can no longer happen before the hard limit. */ + thresholdPercent: number; +}; + +/** Gate bounds shared by the settings slider and the runtime clamp. */ +export const DYNAMIC_CONTEXT_MIN_PERCENT = 40; +export const DYNAMIC_CONTEXT_MAX_PERCENT = 95; +/** Used when a binding predates the setting. */ +export const DYNAMIC_CONTEXT_DEFAULT_PERCENT = 60; + +/** + * The default gate for a window, per the plan's "reserve one task's worth of + * room" rule: `100% − max(100k, 15% × window) / window`, clamped to the bounds. + * A larger window reserves proportionally less, so its gate sits higher (less + * narrowing, later); small windows are held at the floor. + */ +export function defaultDynamicContextPercent(contextWindow: number): number { + const window = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 0; + if (window <= 0) return DYNAMIC_CONTEXT_DEFAULT_PERCENT; + const reserved = Math.max(100_000, window * 0.15); + const percent = Math.round(100 - (reserved / window) * 100); + return clampDynamicContextPercent(percent); +} + +export function clampDynamicContextPercent(percent: number): number { + if (!Number.isFinite(percent)) return DYNAMIC_CONTEXT_DEFAULT_PERCENT; + return Math.min( + DYNAMIC_CONTEXT_MAX_PERCENT, + Math.max(DYNAMIC_CONTEXT_MIN_PERCENT, Math.round(percent)), + ); +} +/** + * Early background compaction: the share of the hard limit at + * which a settled, idle session compacts itself in the background, instead of + * waiting for the next prompt to hit the hard limit on the critical path. + * + * Absent means "the defaults" (`defaultEarlyCompaction`): the pass runs at 75 % + * of the hard limit after 120 s without activity, and a *successful* background + * pass stays out of the toast channel — the transcript row, the context + * inspector and `recall` remain, so the compaction is discoverable rather than + * hidden. `silent: false` restores the warning toast; `enabled: false` turns + * the background pass off entirely; the inline hard-limit path is unaffected by + * all three and always warns. + */ +export type ModelEarlyCompaction = { + enabled: boolean; + /** 50–95; below the floor a session compacts while still roomy, above the + * ceiling the pass can no longer beat the hard limit to the punch. */ + thresholdPercent: number; + /** Idle seconds before the pass runs; any new prompt cancels it. */ + delaySeconds: number; + /** Keep a successful background pass out of the toast channel. */ + silent: boolean; +}; + +/** Early-compaction bounds shared by the settings controls and the runtime. */ +export const EARLY_COMPACTION_MIN_PERCENT = 50; +export const EARLY_COMPACTION_MAX_PERCENT = 95; +export const EARLY_COMPACTION_DEFAULT_PERCENT = 75; +export const EARLY_COMPACTION_MIN_DELAY_SECONDS = 15; +export const EARLY_COMPACTION_MAX_DELAY_SECONDS = 900; +export const EARLY_COMPACTION_DEFAULT_DELAY_SECONDS = 120; +export const EARLY_COMPACTION_DEFAULT_SILENT = true; + +export function clampEarlyCompactionPercent(percent: number): number { + if (!Number.isFinite(percent)) return EARLY_COMPACTION_DEFAULT_PERCENT; + return Math.min( + EARLY_COMPACTION_MAX_PERCENT, + Math.max(EARLY_COMPACTION_MIN_PERCENT, Math.round(percent)), + ); +} + +export function clampEarlyCompactionDelaySeconds(seconds: number): number { + if (!Number.isFinite(seconds)) return EARLY_COMPACTION_DEFAULT_DELAY_SECONDS; + return Math.min( + EARLY_COMPACTION_MAX_DELAY_SECONDS, + Math.max(EARLY_COMPACTION_MIN_DELAY_SECONDS, Math.round(seconds)), + ); +} + +/** The settings a binding without `earlyCompaction` is compiled against. */ +export function defaultEarlyCompaction(): ModelEarlyCompaction { + return { + enabled: true, + thresholdPercent: EARLY_COMPACTION_DEFAULT_PERCENT, + delaySeconds: EARLY_COMPACTION_DEFAULT_DELAY_SECONDS, + silent: EARLY_COMPACTION_DEFAULT_SILENT, + }; +} + +/** Sleep-time digest: a deterministic record of where the session stands, + written while the session is idle and a compaction is still ahead of it. + + It is **off by default** and it never calls a provider: the digest is + extracted from the transcript by the same deterministic pass the degradation + ladder uses, so a summary request that never comes back still leaves the next + window with the goal, the unresolved failures and the stated next step — + captured *before* the attempt instead of after it. The only cost is a bounded + amount of transcript, which is why the quota exists. */ +export type ModelSleepTime = { + enabled: boolean; + /** 1–12 digest runs per hour of session time. */ + maxRunsPerHour: number; +}; + +export const SLEEP_TIME_MIN_RUNS_PER_HOUR = 1; +export const SLEEP_TIME_MAX_RUNS_PER_HOUR = 12; +export const SLEEP_TIME_DEFAULT_RUNS_PER_HOUR = 2; + +export function clampSleepTimeRunsPerHour(runs: number): number { + if (!Number.isFinite(runs)) return SLEEP_TIME_DEFAULT_RUNS_PER_HOUR; + return Math.min( + SLEEP_TIME_MAX_RUNS_PER_HOUR, + Math.max(SLEEP_TIME_MIN_RUNS_PER_HOUR, Math.round(runs)), + ); +} + +/** The settings a binding without `sleepTime` is compiled against: off. */ +export function defaultSleepTime(): ModelSleepTime { + return { + enabled: false, + maxRunsPerHour: SLEEP_TIME_DEFAULT_RUNS_PER_HOUR, + }; +} + export const MODEL_MODALITIES = ["text", "image", "audio", "video", "pdf"] as const; export type ModelModality = (typeof MODEL_MODALITIES)[number]; diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts index 1196253962..e48aa3fe14 100644 --- a/packages/shared/src/types/sessions.ts +++ b/packages/shared/src/types/sessions.ts @@ -86,6 +86,14 @@ export type ContextCompactionRecord = { firstKeptMessageId?: string; throughMessageId: string; tokensBefore: number; + /** + * Occupancy the checkpoint itself believes the next request will carry, + * stamped when the checkpoint is installed. A compaction leaves the message + * transcript untouched, so without this the context ring keeps showing the + * pre-compaction request until the next provider response lands. Optional: + * a checkpoint written before the field existed has none. + */ + tokensAfter?: number; usage?: unknown; retainedTail?: unknown[]; details?: unknown; @@ -122,6 +130,8 @@ export type ContextCompactionMark = ContextCompactionStatus & { * notice as a summary. */ fallback?: ContextCompactionFallback; + /** Carried from the record so the ring can lead with it (see above). */ + tokensAfter?: number; }; export type ContextCompactionReason = "manual" | "threshold" | "overflow";