@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +3725 -4029
  3. package/dist/types/cli/args.d.ts +2 -5
  4. package/dist/types/cli/flag-tables.d.ts +2 -2
  5. package/dist/types/cli/session-picker.d.ts +4 -2
  6. package/dist/types/commands/launch.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +12 -1
  8. package/dist/types/eval/agent-bridge.d.ts +19 -0
  9. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  10. package/dist/types/eval/js/shared/types.d.ts +1 -1
  11. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  12. package/dist/types/eval/py/executor.d.ts +1 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  14. package/dist/types/main.d.ts +2 -0
  15. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  16. package/dist/types/modes/components/session-selector.d.ts +25 -0
  17. package/dist/types/task/isolation-runner.d.ts +128 -0
  18. package/dist/types/task/worktree.d.ts +14 -1
  19. package/dist/types/thinking.d.ts +15 -0
  20. package/dist/types/tools/eval-render.d.ts +3 -0
  21. package/dist/types/tools/eval.d.ts +11 -17
  22. package/dist/types/tools/todo.d.ts +26 -28
  23. package/dist/types/tui/output-block.d.ts +8 -0
  24. package/dist/types/utils/image-resize.d.ts +2 -0
  25. package/dist/types/web/search/providers/exa.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/scripts/build-binary.ts +18 -4
  28. package/src/cli/args.ts +4 -5
  29. package/src/cli/flag-tables.ts +3 -3
  30. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  31. package/src/cli/gallery-fixtures/shell.ts +15 -23
  32. package/src/cli/session-picker.ts +17 -3
  33. package/src/commands/launch.ts +3 -3
  34. package/src/config/settings-schema.ts +13 -1
  35. package/src/edit/renderer.ts +34 -12
  36. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  37. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  38. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  39. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  40. package/src/eval/agent-bridge.ts +301 -71
  41. package/src/eval/jl/prelude.jl +32 -227
  42. package/src/eval/jl/runner.jl +38 -12
  43. package/src/eval/js/shared/helpers.ts +1 -114
  44. package/src/eval/js/shared/prelude.txt +13 -27
  45. package/src/eval/js/shared/runtime.ts +0 -6
  46. package/src/eval/js/shared/types.ts +1 -1
  47. package/src/eval/js/worker-protocol.ts +1 -1
  48. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  49. package/src/eval/py/executor.ts +1 -1
  50. package/src/eval/py/prelude.py +47 -105
  51. package/src/eval/py/runner.py +0 -6
  52. package/src/eval/rb/prelude.rb +21 -189
  53. package/src/eval/rb/runner.rb +116 -9
  54. package/src/export/html/tool-views.generated.js +29 -29
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/local-protocol.ts +100 -53
  57. package/src/main.ts +15 -4
  58. package/src/modes/acp/acp-event-mapper.ts +7 -2
  59. package/src/modes/components/plugin-settings.ts +7 -1
  60. package/src/modes/components/session-selector.ts +143 -29
  61. package/src/modes/controllers/command-controller.ts +5 -0
  62. package/src/modes/rpc/rpc-mode.ts +6 -0
  63. package/src/modes/utils/copy-targets.ts +7 -2
  64. package/src/prompts/system/system-prompt.md +3 -3
  65. package/src/prompts/system/workflow-notice.md +3 -3
  66. package/src/prompts/tools/bash.md +16 -0
  67. package/src/prompts/tools/eval.md +19 -19
  68. package/src/prompts/tools/todo.md +1 -1
  69. package/src/session/agent-session.ts +231 -50
  70. package/src/task/index.ts +61 -207
  71. package/src/task/isolation-runner.ts +354 -0
  72. package/src/task/worktree.ts +46 -9
  73. package/src/thinking.ts +20 -0
  74. package/src/tools/ask.ts +44 -38
  75. package/src/tools/bash.ts +9 -2
  76. package/src/tools/browser/tab-worker.ts +1 -1
  77. package/src/tools/eval-render.ts +34 -27
  78. package/src/tools/eval.ts +100 -103
  79. package/src/tools/index.ts +8 -1
  80. package/src/tools/read.ts +136 -60
  81. package/src/tools/todo.ts +60 -64
  82. package/src/tui/code-cell.ts +1 -1
  83. package/src/tui/output-block.ts +11 -0
  84. package/src/utils/image-resize.ts +30 -0
  85. package/src/web/search/providers/exa.ts +85 -1
@@ -1,21 +1,23 @@
1
- Run code in a persistent kernel using a list of cells.
1
+ Run one step of code in a persistent kernel.
2
2
 
3
3
  <instruction>
4
- Cells run in array order. State persists per language across cells, tool calls, and `task` subagents — stage helpers/datasets/clients once, subagents reuse directly, no re-import/serialize.
4
+ **One eval call = one cell = one logical step.** State persists per language across separate eval calls, tool calls, and `task` subagents — define helpers/datasets/clients in one call, then later calls reuse them directly.
5
5
 
6
- Cell fields:
6
+ Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after `reset`, a kernel crash, or a `NameError`/`ReferenceError` proving the state is gone. Parallelize work *within* a cell with the `parallel(thunks)` helper, not by batching steps.
7
+
8
+ Fields:
7
9
 
8
10
  - `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}.
9
11
  - `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
10
12
  - `title` (optional) — short transcript label (e.g. `"imports"`).
11
- - `timeout` (optional) — per-cell seconds. Raise only for heavy compute or long non-agent tool calls.
12
- - `reset` (optional) — wipe this cell's language kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
13
+ - `timeout` (optional) — seconds. Raise only for heavy compute or long non-agent tool calls.
14
+ - `reset` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
13
15
 
14
- Work incrementally — one logical step per cell (imports, define, test, use), many small cells per call; workflow notes in the assistant message or `title`, never in cell code.
15
16
  {{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
16
- {{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `tree(".", max_depth: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
17
- {{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `tree(max_depth=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
18
- Errors name the failing cell ("Cell 3 failed") resubmit the fixed cell + any remaining.
17
+ {{#if js}}JS runs under **Bun**: Bun globals/APIs are available (`Bun.file`, `Bun.write`, `Bun.$`, `fetch`, `Buffer`); top-level `await`/`return` work directly.{{/if}}
18
+ {{#if rb}}Ruby: synchronous; helper options are keyword args (e.g. `output("id", limit: 2)`); the last expression auto-displays unless it is `nil`, an assignment, or a definition (like IRB).{{/if}}
19
+ {{#if jl}}Julia: synchronous; helper options are standard keyword args (e.g. `output("id", limit=2)`); the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
20
+ On error, fix and re-run only the failing step — prior calls' state survives.
19
21
  </instruction>
20
22
 
21
23
  <prelude>
@@ -29,12 +31,6 @@ read(path, offset?=1, limit?=None) → str
29
31
  File as text; offset/limit 1-indexed lines. Accepts `local://…`.
30
32
  write(path, content) → str
31
33
  Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
32
- append(path, content) → str
33
- Append → resolved path. Accepts `local://…`.
34
- tree(path?=".", max_depth?=3, show_hidden?=False) → str
35
- Directory tree.
36
- diff(a, b) → str
37
- Unified diff of two files.
38
34
  env(key?=None, value?=None) → str | None | dict
39
35
  No args → full env dict; one → value of `key`; two → set `key=value`, return value.
40
36
  output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
@@ -43,9 +39,9 @@ tool.<name>(args) → unknown
43
39
  Invoke any session tool; `args` = its parameter object.
44
40
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
45
41
  Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
46
- {{#if spawns}}agent(prompt, agent_type?="task", model?=None, label?=None, schema?=None, return_handle?=False) → str | dict
47
- Run a subagent → final output. `agent_type`/`agentType` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `return_handle`/`returnHandle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
48
- {{#if js}} JS: options are ONE trailing object — agent(prompt, { agentType, schema, returnHandle }).
42
+ {{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
+ Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
44
+ {{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
49
45
  {{/if}}
50
46
  {{/if}}
51
47
  parallel(thunks) → list
@@ -63,10 +59,14 @@ budget → per-turn token budget
63
59
  {{#if spawns}}
64
60
  <dag>
65
61
  Pipe handles through stage helpers to build a dependency graph — acyclic waves:
66
- - **Name nodes.** Capture each `agent(…, {{#if py}}return_handle=True{{/if}}{{#if js}}{ returnHandle: true }{{/if}}{{#if jl}}return_handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
62
+ - **Name nodes.** Capture each `agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})` result; carries `handle` (`agent://<id>`) + `output`.
67
63
  - **Wire edges by reference.** Put an upstream node's `handle`/`output` in the dependent stage's prompt — large transcript never re-inlined. Bulk: `write("local://<name>.md", …)`, pass the URI.
68
64
  - **`pipeline(items, *stages)` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **`parallel(thunks)` = one wave** of independent nodes.
69
65
  - **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
70
66
  - **Acyclic only.** A node never waits on its own descendant.
71
67
  </dag>
72
68
  {{/if}}
69
+
70
+ <critical>
71
+ Prior top-level names (`data`, `sessions`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read. Re-run setup only after `reset`, a crash, or a `NameError`/`ReferenceError`.
72
+ </critical>
@@ -1,6 +1,6 @@
1
1
  **Tasks referenced by verbatim content string, NEVER an auto-generated ID — no "task-1"/"task-N" exists. Pass the content text in the `task` field.**
2
2
 
3
- Manages a phased task list. Pass `ops`: flat array of operations. Next pending task auto-promotes to `in_progress` on each completion. `pending` is a status, not an `op` — leave not-yet-started tasks implicit in `init`/`append`.
3
+ Next pending task auto-promotes to `in_progress` on each completion.
4
4
 
5
5
  ## Operations
6
6
 
@@ -55,8 +55,9 @@ import {
55
55
  effectiveReserveTokens,
56
56
  estimateTokens,
57
57
  generateBranchSummary,
58
- generateHandoff,
58
+ generateHandoffFromContext,
59
59
  prepareCompaction,
60
+ renderHandoffPrompt,
60
61
  resolveThresholdTokens,
61
62
  type SessionEntry,
62
63
  type SessionMessageEntry,
@@ -103,7 +104,7 @@ import {
103
104
  resolveServiceTier,
104
105
  streamSimple,
105
106
  } from "@oh-my-pi/pi-ai";
106
- import { stripToolDescriptions, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
+ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
107
108
  import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
108
109
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
109
110
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -1619,6 +1620,7 @@ export class AgentSession {
1619
1620
  await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1620
1621
  }
1621
1622
  }
1623
+ await this.#maintainContextMidRun(messages, signal);
1622
1624
  });
1623
1625
  this.yieldQueue = new YieldQueue({
1624
1626
  isStreaming: () => this.isStreaming,
@@ -2777,10 +2779,32 @@ export class AgentSession {
2777
2779
  this.#lastAssistantMessage = undefined;
2778
2780
  if (!msg) {
2779
2781
  this.#lastSuccessfulYieldToolCallId = undefined;
2782
+ logger.debug("agent_end maintenance routing", {
2783
+ reason: "no-assistant-message",
2784
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2785
+ goalStatus: this.#goalModeState?.goal.status,
2786
+ });
2780
2787
  await emitAgentEndNotification();
2781
2788
  return;
2782
2789
  }
2783
2790
 
2791
+ const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
2792
+ logger.debug("agent_end maintenance routing", {
2793
+ route,
2794
+ stopReason: msg.stopReason,
2795
+ provider: msg.provider,
2796
+ model: msg.model,
2797
+ contentBlocks: msg.content.length,
2798
+ hasToolCalls: msg.content.some(content => content.type === "toolCall"),
2799
+ hasText: msg.content.some(content => content.type === "text"),
2800
+ goalModeEnabled: this.#goalModeState?.enabled === true,
2801
+ goalStatus: this.#goalModeState?.goal.status,
2802
+ successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
2803
+ ...extra,
2804
+ });
2805
+ };
2806
+ maintenanceRoute("entered");
2807
+
2784
2808
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
2785
2809
  // aren't reused on the next request
2786
2810
  if (
@@ -2794,27 +2818,61 @@ export class AgentSession {
2794
2818
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
2795
2819
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
2796
2820
  this.#lastSuccessfulYieldToolCallId = undefined;
2821
+ maintenanceRoute("skip-post-turn-maintenance");
2797
2822
  await emitAgentEndNotification();
2798
2823
  return;
2799
2824
  }
2800
2825
 
2826
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2801
2827
  if (this.#assistantEndedWithSuccessfulYield(msg)) {
2802
2828
  this.#lastSuccessfulYieldToolCallId = undefined;
2803
- if (this.#goalModeState?.enabled && this.#goalModeState.goal.status === "active") {
2829
+ if (activeGoal) {
2830
+ maintenanceRoute("successful-yield-active-goal-checkCompaction");
2804
2831
  const compactionTask = this.#checkCompaction(msg);
2805
2832
  this.#trackPostPromptTask(compactionTask);
2806
2833
  await compactionTask;
2834
+ } else {
2835
+ maintenanceRoute("successful-yield-no-active-goal");
2807
2836
  }
2808
2837
  await emitAgentEndNotification();
2809
2838
  return;
2810
2839
  }
2811
2840
  this.#lastSuccessfulYieldToolCallId = undefined;
2812
2841
 
2842
+ // Empty-stop cleanup MUST run before any compaction continuation: an
2843
+ // empty toolUse stop must be stripped from active context + session
2844
+ // history before we schedule another turn, otherwise the next
2845
+ // Anthropic turn carries a tool_use block with no matching
2846
+ // tool_result and corrupts message history. The handler also
2847
+ // schedules its own retry, so a real empty stop never needs the
2848
+ // active-goal threshold pre-empt below.
2813
2849
  if (await this.#handleEmptyAssistantStop(msg)) {
2850
+ maintenanceRoute("empty-stop-handled");
2814
2851
  await emitAgentEndNotification();
2815
2852
  return;
2816
2853
  }
2854
+
2855
+ let compactionResult = COMPACTION_CHECK_NONE;
2856
+ let checkedCompaction = false;
2857
+ if (activeGoal) {
2858
+ maintenanceRoute("active-goal-pre-empt-checkCompaction");
2859
+ const compactionTask = this.#checkCompaction(msg);
2860
+ this.#trackPostPromptTask(compactionTask);
2861
+ compactionResult = await compactionTask;
2862
+ checkedCompaction = true;
2863
+ if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
2864
+ maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
2865
+ deferredHandoff: compactionResult.deferredHandoff,
2866
+ continuationScheduled: compactionResult.continuationScheduled,
2867
+ });
2868
+ this.#resolveRetry();
2869
+ await emitAgentEndNotification();
2870
+ return;
2871
+ }
2872
+ }
2873
+
2817
2874
  if (await this.#handleUnexpectedAssistantStop(msg)) {
2875
+ maintenanceRoute("unexpected-stop-handled");
2818
2876
  await emitAgentEndNotification();
2819
2877
  return;
2820
2878
  }
@@ -2854,9 +2912,12 @@ export class AgentSession {
2854
2912
  }
2855
2913
  this.#resolveRetry();
2856
2914
 
2857
- const compactionTask = this.#checkCompaction(msg);
2858
- this.#trackPostPromptTask(compactionTask);
2859
- const compactionResult = await compactionTask;
2915
+ if (!checkedCompaction) {
2916
+ maintenanceRoute("bottom-checkCompaction");
2917
+ const compactionTask = this.#checkCompaction(msg);
2918
+ this.#trackPostPromptTask(compactionTask);
2919
+ compactionResult = await compactionTask;
2920
+ }
2860
2921
  // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
2861
2922
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
2862
2923
  if (hasToolCalls) {
@@ -8049,27 +8110,60 @@ export class AgentSession {
8049
8110
  throw new Error(`No API key for ${model.provider}`);
8050
8111
  }
8051
8112
 
8052
- const rawHandoffText = await generateHandoff(
8053
- this.agent.state.messages,
8054
- model,
8055
- this.#modelRegistry.resolver(model, this.sessionId),
8113
+ // Build the handoff request through the SAME pipeline a live turn uses
8114
+ // (`runEphemeralTurn` / `/btw` share it) so the oneshot reads the
8115
+ // provider prompt cache the main turn populated instead of cold-missing
8116
+ // the whole prefix: identical system prompt, normalized tools, and
8117
+ // transform-/obfuscation-matched message history via
8118
+ // `convertMessagesToLlm` + `buildSideRequestContext`, plus the live turn's
8119
+ // effective provider cache key with a unique side `sessionId` so
8120
+ // OpenAI/Codex append-only state never mixes with the live turn.
8121
+ const cacheSessionId = this.sessionId;
8122
+ // The loop sends `promptCacheKey` (providerPromptCacheKey) and falls back to
8123
+ // the provider session id; providers route on `promptCacheKey ?? sessionId`.
8124
+ // Both can diverge from this.sessionId (tan/subagent/shared sessions), so
8125
+ // mirror exactly what the live turn populated the cache under.
8126
+ const handoffPromptCacheKey = this.agent.promptCacheKey ?? this.agent.sessionId;
8127
+ const handoffPromptText = renderHandoffPrompt(this.#obfuscateTextForProvider(customInstructions));
8128
+ const handoffSnapshot: AgentMessage[] = [
8129
+ ...this.agent.state.messages,
8056
8130
  {
8057
- systemPrompt: this.#baseSystemPrompt,
8058
- tools: this.#pruneToolDescriptions
8059
- ? stripToolDescriptions(this.agent.state.tools)
8060
- : this.agent.state.tools,
8061
- customInstructions: this.#obfuscateTextForProvider(customInstructions),
8062
- convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
8131
+ role: "user",
8132
+ content: [{ type: "text", text: handoffPromptText }],
8133
+ attribution: "agent",
8134
+ timestamp: Date.now(),
8135
+ },
8136
+ ];
8137
+ const handoffLlmMessages = await this.convertMessagesToLlm(handoffSnapshot, handoffSignal);
8138
+ // Base system prompt, not a per-turn `before_agent_start` hook override —
8139
+ // the handoff seeds a fresh session and must not carry prompt-specific
8140
+ // hook state. Matches the prompt the old handoff path sent.
8141
+ const handoffContext = await this.agent.buildSideRequestContext(handoffLlmMessages, this.#baseSystemPrompt);
8142
+ const handoffStreamOptions = this.prepareSimpleStreamOptions(
8143
+ {
8144
+ apiKey: this.#modelRegistry.resolver(model, cacheSessionId),
8145
+ sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
8146
+ promptCacheKey: handoffPromptCacheKey,
8147
+ preferWebsockets: false,
8148
+ serviceTier: this.#effectiveServiceTier(model),
8149
+ hideThinkingSummary: this.agent.hideThinkingSummary,
8063
8150
  initiatorOverride: "agent",
8064
- metadata: this.agent.metadataForProvider(model.provider),
8151
+ signal: handoffSignal,
8152
+ },
8153
+ model.provider,
8154
+ );
8155
+ const rawHandoffText = await generateHandoffFromContext(
8156
+ obfuscateProviderContext(this.#obfuscator, handoffContext),
8157
+ model,
8158
+ {
8159
+ streamOptions: handoffStreamOptions,
8065
8160
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
8066
- // Honor the user's /model thinking selection on the handoff
8067
- // path. Clamped per-model inside generateHandoff via
8068
- // resolveCompactionEffort so unsupported-effort models don't
8069
- // trip requireSupportedEffort.
8161
+ // Honor the user's /model thinking selection on the handoff path.
8162
+ // Clamped per-model inside generateHandoffFromContext via
8163
+ // resolveCompactionEffort so unsupported-effort models don't trip
8164
+ // requireSupportedEffort.
8070
8165
  thinkingLevel: this.thinkingLevel,
8071
8166
  },
8072
- handoffSignal,
8073
8167
  );
8074
8168
  const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
8075
8169
 
@@ -8213,6 +8307,58 @@ export class AgentSession {
8213
8307
  });
8214
8308
  }
8215
8309
 
8310
+ /**
8311
+ * Compact active `/goal` runs that never settle to `agent_end`.
8312
+ *
8313
+ * Long autonomous goals can keep producing tool calls inside one agent run.
8314
+ * The post-turn `agent_end` threshold check never fires in that shape, so
8315
+ * context can grow until provider overflow. `onTurnEnd` is the safe boundary:
8316
+ * tool results for the just-finished turn are already paired in
8317
+ * `activeMessages`, the live array the agent loop reads before its next
8318
+ * model call. Run maintenance here and splice the compacted state back into
8319
+ * that array, mirroring [`AgentSession.#applyRewind`].
8320
+ */
8321
+ async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
8322
+ if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
8323
+ if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
8324
+
8325
+ const model = this.model;
8326
+ const contextWindow = model?.contextWindow ?? 0;
8327
+ if (contextWindow <= 0) return;
8328
+
8329
+ const compactionSettings = this.settings.getGroup("compaction");
8330
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
8331
+
8332
+ const lastAssistant = [...activeMessages]
8333
+ .reverse()
8334
+ .find((message): message is AssistantMessage => message.role === "assistant");
8335
+ if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
8336
+
8337
+ const billedContextTokens = calculateContextTokens(lastAssistant.usage);
8338
+ const storedContextTokens = this.#estimateStoredContextTokens();
8339
+ const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
8340
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
8341
+
8342
+ const messagesBefore = activeMessages.length;
8343
+ await this.#runAutoCompaction("threshold", false, false, false, {
8344
+ autoContinue: false,
8345
+ suppressContinuation: true,
8346
+ triggerContextTokens: contextTokens,
8347
+ });
8348
+
8349
+ if (signal?.aborted) return;
8350
+ const compactedMessages = this.agent.state.messages;
8351
+ if (compactedMessages !== activeMessages) {
8352
+ activeMessages.splice(0, activeMessages.length, ...compactedMessages);
8353
+ }
8354
+ logger.debug("Mid-run goal compaction ran between tool-call turns", {
8355
+ contextTokens,
8356
+ contextWindow,
8357
+ strategy: compactionSettings.strategy,
8358
+ messagesBefore,
8359
+ messagesAfter: activeMessages.length,
8360
+ });
8361
+ }
8216
8362
  /**
8217
8363
  * Check if context maintenance or promotion is needed and run it.
8218
8364
  * Called after agent_end and before prompt submission.
@@ -8338,28 +8484,58 @@ export class AgentSession {
8338
8484
  // Skip if this was an error (non-overflow errors don't have usage data)
8339
8485
  if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
8340
8486
  const pruneResult = await this.#pruneToolOutputs();
8341
- let contextTokens = calculateContextTokens(assistantMessage.usage);
8342
- if (supersedeResult) {
8343
- contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
8344
- }
8345
- if (pruneResult) {
8346
- contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
8347
- }
8348
- // Floor by the real stored-conversation estimate so a payload-shrinking
8349
- // before_provider_request hook (e.g. a compression extension such as
8350
- // Headroom) can't deflate the provider-reported usage below the true
8351
- // history size and skip the threshold. The estimate runs after the prune
8352
- // passes above, so it reflects the post-prune message set.
8353
- contextTokens = compactionContextTokens(contextTokens, this.#estimateStoredContextTokens());
8354
- if (shouldCompact(contextTokens, contextWindow, compactionSettings)) {
8487
+ const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
8488
+ const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
8489
+ const storedContextTokens = this.#estimateStoredContextTokens();
8490
+ // Pruning frees bytes for the NEXT prompt; it does not change the size of
8491
+ // the prompt the LLM just billed for. Earlier revisions subtracted the
8492
+ // per-turn supersede/prune `tokensSaved` from the threshold input, which
8493
+ // let a long-running `/goal` session sit above `compaction.thresholdTokens`
8494
+ // indefinitely whenever per-turn pruning saved enough to drop the
8495
+ // post-prune estimate below the user-configured trigger the visible
8496
+ // context (anchored to the same provider billing) still showed >threshold,
8497
+ // but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
8498
+ // last turn's billed context tokens, floored by the post-prune
8499
+ // stored-conversation estimate so a payload-compression hook still can't
8500
+ // deflate the trigger.
8501
+ const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
8502
+ const postMaintenanceContextTokens = compactionContextTokens(
8503
+ Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
8504
+ storedContextTokens,
8505
+ );
8506
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
8507
+ const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
8508
+ logger.debug("Auto-compaction threshold decision", {
8509
+ phase: "post-agent-end",
8510
+ goalModeEnabled: this.#goalModeState?.enabled === true,
8511
+ goalStatus: this.#goalModeState?.goal.status,
8512
+ stopReason: assistantMessage.stopReason,
8513
+ sameModel: sameModel === true,
8514
+ contextWindow,
8515
+ strategy: compactionSettings.strategy,
8516
+ thresholdTokens,
8517
+ assistantUsageContextTokens,
8518
+ storedContextTokens,
8519
+ resolvedContextTokens: contextTokens,
8520
+ postMaintenanceContextTokens,
8521
+ maintenanceTokensFreed,
8522
+ shouldCompact: shouldThresholdCompact,
8523
+ contextPromotionEnabled: this.settings.get("contextPromotion.enabled") === true,
8524
+ });
8525
+ if (shouldThresholdCompact) {
8355
8526
  // Try promotion first — if a larger model is available, switch instead of compacting
8356
8527
  const promoted = await this.#tryContextPromotion(assistantMessage);
8357
8528
  if (!promoted) {
8358
8529
  return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
8359
8530
  autoContinue,
8360
- triggerContextTokens: contextTokens,
8531
+ triggerContextTokens: postMaintenanceContextTokens,
8361
8532
  });
8362
8533
  }
8534
+ logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
8535
+ contextTokens,
8536
+ contextWindow,
8537
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
8538
+ });
8363
8539
  }
8364
8540
  return COMPACTION_CHECK_NONE;
8365
8541
  }
@@ -9483,13 +9659,15 @@ export class AgentSession {
9483
9659
  willRetry: boolean,
9484
9660
  deferred = false,
9485
9661
  allowDefer = true,
9486
- options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
9662
+ options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
9487
9663
  ): Promise<CompactionCheckResult> {
9488
9664
  const compactionSettings = this.settings.getGroup("compaction");
9489
9665
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
9490
9666
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
9491
9667
  const generation = this.#promptGeneration;
9492
- const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
9668
+ const suppressContinuation = options.suppressContinuation === true;
9669
+ const shouldAutoContinue =
9670
+ !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
9493
9671
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
9494
9672
  // reclaims nothing we fall through to the summary-compaction body below so
9495
9673
  // the oversized input still gets resolved.
@@ -9500,6 +9678,7 @@ export class AgentSession {
9500
9678
  generation,
9501
9679
  shouldAutoContinue,
9502
9680
  options.triggerContextTokens,
9681
+ suppressContinuation,
9503
9682
  );
9504
9683
  if (outcome !== "fallback") return outcome;
9505
9684
  }
@@ -9953,7 +10132,7 @@ export class AgentSession {
9953
10132
 
9954
10133
  this.#scheduleAgentContinue({ delayMs: 100, generation });
9955
10134
  continuationScheduled = true;
9956
- } else if (this.agent.hasQueuedMessages()) {
10135
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
9957
10136
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
9958
10137
  // Kick the loop so queued messages are actually delivered.
9959
10138
  this.#scheduleAgentContinue({
@@ -10013,6 +10192,7 @@ export class AgentSession {
10013
10192
  generation: number,
10014
10193
  autoContinue: boolean,
10015
10194
  triggerContextTokens?: number,
10195
+ suppressContinuation = false,
10016
10196
  ): Promise<CompactionCheckResult | "fallback"> {
10017
10197
  const action = "shake";
10018
10198
  this.#autoCompactionAbortController?.abort();
@@ -10043,15 +10223,16 @@ export class AgentSession {
10043
10223
  // situation actually resolves; "idle" is exempt because its 60s+ timer
10044
10224
  // re-checks usage before re-firing and cannot dead-loop on its own.
10045
10225
  //
10046
- // #2275: the post-shake check MUST be anchored on the same metric that
10047
- // triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
10048
- // undercounts thinking-signature payloads, so on thinking-heavy sessions it
10049
- // reads well below the provider-reported usage that fired the threshold.
10050
- // When that estimate slips under the threshold, the fallback never fires
10051
- // and the auto-continue prompt re-injects every turn. Prefer the trigger's
10052
- // own `contextTokens` (provider-anchored) when the caller supplies it, and
10053
- // add hysteresis (80% recovery band) so we don't oscillate at the boundary
10054
- // while shake keeps reclaiming a trickle of the previous turn's output.
10226
+ // #2275: the post-shake check MUST stay provider-anchored when caller
10227
+ // usage and local estimates diverge. The local estimator undercounts
10228
+ // thinking-signature payloads, so thinking-heavy sessions can read well
10229
+ // below the provider usage that fired the threshold. Prefer the caller's
10230
+ // context figure when supplied, then subtract shake's own savings and add
10231
+ // hysteresis (80% recovery band) so we don't oscillate at the boundary.
10232
+ // Threshold callers pass the provider-billed trigger after accounting for
10233
+ // any supersede/drop-useless pruning that already rewrote the next prompt;
10234
+ // without that pre-shake savings, shake can fall through to context-full
10235
+ // even though the post-prune history is already inside the recovery band.
10055
10236
  const contextWindow = this.model?.contextWindow ?? 0;
10056
10237
  const compactionSettings = this.settings.getGroup("compaction");
10057
10238
  let stillOverThreshold = false;
@@ -10111,7 +10292,7 @@ export class AgentSession {
10111
10292
  }
10112
10293
  this.#scheduleAgentContinue({ delayMs: 100, generation });
10113
10294
  continuationScheduled = true;
10114
- } else if (this.agent.hasQueuedMessages()) {
10295
+ } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
10115
10296
  this.#scheduleAgentContinue({
10116
10297
  delayMs: 100,
10117
10298
  generation,