@oh-my-pi/pi-coding-agent 15.13.1 → 15.13.3

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 (109) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli.js +1057 -289
  3. package/dist/types/config/model-registry.d.ts +1 -0
  4. package/dist/types/config/models-config-schema.d.ts +3 -0
  5. package/dist/types/config/models-config.d.ts +3 -0
  6. package/dist/types/config/settings-schema.d.ts +97 -0
  7. package/dist/types/edit/hashline/block-resolver.d.ts +1 -1
  8. package/dist/types/edit/index.d.ts +2 -0
  9. package/dist/types/eval/js/context-manager.d.ts +15 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/input-controller.d.ts +4 -4
  12. package/dist/types/modes/interactive-mode.d.ts +1 -0
  13. package/dist/types/modes/rpc/rpc-types.d.ts +2 -1
  14. package/dist/types/modes/types.d.ts +6 -0
  15. package/dist/types/sdk.d.ts +3 -0
  16. package/dist/types/session/session-dump-format.d.ts +2 -1
  17. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  18. package/dist/types/stt/asr-client.d.ts +1 -1
  19. package/dist/types/system-prompt.d.ts +11 -0
  20. package/dist/types/tiny/title-client.d.ts +1 -1
  21. package/dist/types/tools/ask.d.ts +2 -0
  22. package/dist/types/tools/ast-edit.d.ts +2 -0
  23. package/dist/types/tools/ast-grep.d.ts +2 -0
  24. package/dist/types/tools/browser.d.ts +2 -0
  25. package/dist/types/tools/debug.d.ts +2 -0
  26. package/dist/types/tools/eval.d.ts +2 -0
  27. package/dist/types/tools/find.d.ts +2 -0
  28. package/dist/types/tools/inspect-image.d.ts +2 -1
  29. package/dist/types/tools/irc.d.ts +2 -0
  30. package/dist/types/tools/job.d.ts +1 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/tools/todo.d.ts +2 -0
  33. package/dist/types/tts/tts-client.d.ts +1 -1
  34. package/dist/types/tui/tree-list.d.ts +1 -0
  35. package/dist/types/utils/thinking-display.d.ts +1 -17
  36. package/package.json +12 -12
  37. package/src/cli.ts +25 -12
  38. package/src/config/model-registry.ts +16 -2
  39. package/src/config/models-config-schema.ts +2 -0
  40. package/src/config/models-config.ts +1 -0
  41. package/src/config/settings-schema.ts +78 -0
  42. package/src/edit/hashline/block-resolver.ts +1 -1
  43. package/src/edit/hashline/execute.ts +1 -6
  44. package/src/edit/index.ts +48 -0
  45. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  46. package/src/eval/__tests__/js-context-manager.test.ts +53 -3
  47. package/src/eval/js/context-manager.ts +132 -29
  48. package/src/eval/js/worker-core.ts +1 -1
  49. package/src/eval/js/worker-entry.ts +7 -0
  50. package/src/export/html/template.js +18 -22
  51. package/src/internal-urls/docs-index.generated.ts +12 -3
  52. package/src/main.ts +15 -5
  53. package/src/modes/acp/acp-agent.ts +2 -2
  54. package/src/modes/acp/acp-event-mapper.ts +2 -2
  55. package/src/modes/components/agent-hub.ts +31 -7
  56. package/src/modes/components/assistant-message.ts +24 -15
  57. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  58. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  59. package/src/modes/components/tree-selector.ts +3 -2
  60. package/src/modes/components/welcome.ts +14 -4
  61. package/src/modes/controllers/event-controller.ts +3 -3
  62. package/src/modes/controllers/input-controller.ts +28 -39
  63. package/src/modes/controllers/streaming-reveal.ts +4 -4
  64. package/src/modes/interactive-mode.ts +2 -0
  65. package/src/modes/rpc/rpc-mode.ts +1 -0
  66. package/src/modes/rpc/rpc-types.ts +2 -2
  67. package/src/modes/types.ts +6 -0
  68. package/src/modes/utils/ui-helpers.ts +3 -3
  69. package/src/prompts/agents/oracle.md +0 -1
  70. package/src/prompts/agents/reviewer.md +0 -1
  71. package/src/prompts/system/system-prompt.md +17 -21
  72. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  73. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  74. package/src/prompts/tools/ask.md +0 -8
  75. package/src/prompts/tools/ast-edit.md +0 -15
  76. package/src/prompts/tools/ast-grep.md +0 -13
  77. package/src/prompts/tools/browser.md +0 -21
  78. package/src/prompts/tools/debug.md +0 -13
  79. package/src/prompts/tools/eval.md +0 -9
  80. package/src/prompts/tools/find.md +0 -13
  81. package/src/prompts/tools/inspect-image.md +0 -9
  82. package/src/prompts/tools/irc.md +0 -15
  83. package/src/prompts/tools/patch.md +0 -13
  84. package/src/prompts/tools/ssh.md +0 -9
  85. package/src/prompts/tools/todo.md +1 -19
  86. package/src/sdk.ts +19 -0
  87. package/src/session/agent-session.ts +289 -29
  88. package/src/session/session-dump-format.ts +17 -49
  89. package/src/session/unexpected-stop-classifier.ts +129 -0
  90. package/src/stt/asr-client.ts +1 -1
  91. package/src/system-prompt.ts +31 -0
  92. package/src/tiny/title-client.ts +1 -1
  93. package/src/tools/ask.ts +41 -0
  94. package/src/tools/ast-edit.ts +46 -0
  95. package/src/tools/ast-grep.ts +24 -0
  96. package/src/tools/browser/tab-supervisor.ts +1 -1
  97. package/src/tools/browser/tab-worker-entry.ts +12 -4
  98. package/src/tools/browser.ts +52 -0
  99. package/src/tools/debug.ts +17 -0
  100. package/src/tools/eval.ts +20 -1
  101. package/src/tools/find.ts +24 -0
  102. package/src/tools/inspect-image.ts +27 -1
  103. package/src/tools/irc.ts +41 -0
  104. package/src/tools/job.ts +1 -0
  105. package/src/tools/ssh.ts +16 -0
  106. package/src/tools/todo.ts +82 -3
  107. package/src/tts/tts-client.ts +1 -1
  108. package/src/tui/tree-list.ts +68 -19
  109. package/src/utils/thinking-display.ts +8 -34
@@ -46,7 +46,9 @@ import {
46
46
  collectEntriesForBranchSummary,
47
47
  collectShakeRegions,
48
48
  compact,
49
+ createCompactionSummaryMessage,
49
50
  DEFAULT_SHAKE_CONFIG,
51
+ effectiveReserveTokens,
50
52
  estimateTokens,
51
53
  generateBranchSummary,
52
54
  generateHandoff,
@@ -200,6 +202,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
200
202
  };
201
203
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
202
204
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
205
+ import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
203
206
  import {
204
207
  deobfuscateSessionContext,
205
208
  obfuscateProviderContext,
@@ -268,6 +271,7 @@ import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
268
271
  import type { SessionManager } from "./session-manager";
269
272
  import type { ShakeMode, ShakeResult } from "./shake-types";
270
273
  import { ToolChoiceQueue } from "./tool-choice-queue";
274
+ import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
271
275
  import { YieldQueue } from "./yield-queue";
272
276
 
273
277
  /** Session-specific events that extend the core AgentEvent */
@@ -306,14 +310,16 @@ export type AgentSessionEvent =
306
310
  resolved?: Effort;
307
311
  }
308
312
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
309
-
310
313
  /** Listener function for agent session events */
311
314
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
312
- export type CommandMetadataChangedListener = () => void | Promise<void>;
313
- export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
314
315
 
316
+ const UNEXPECTED_STOP_MAX_RETRIES = 3;
317
+ const UNEXPECTED_STOP_TIMEOUT_MS = 4000;
315
318
  const EMPTY_STOP_MAX_RETRIES = 3;
316
319
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
320
+ export type CommandMetadataChangedListener = () => void | Promise<void>;
321
+ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
322
+
317
323
  const RETRY_BACKOFF_JITTER_RATIO = 0.25;
318
324
  /**
319
325
  * Hysteresis band for the post-shake "did we actually create headroom?" check.
@@ -1104,15 +1110,23 @@ export class AgentSession {
1104
1110
  // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
1105
1111
  // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1106
1112
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1107
- // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
1108
- // both #endInFlight (normal) and #resetInFlight (abort).
1113
+ #emptyStopRetryCount = 0;
1114
+ #unexpectedStopRetryCount = 0;
1115
+ #promptGeneration = 0;
1109
1116
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1117
+ #pendingProviderRequestNonMessageTokens: number | undefined = undefined;
1118
+ #lastProviderUsageNonMessage:
1119
+ | {
1120
+ provider: AssistantMessage["provider"];
1121
+ model: AssistantMessage["model"];
1122
+ timestamp: AssistantMessage["timestamp"];
1123
+ tokens: number;
1124
+ }
1125
+ | undefined;
1110
1126
  #obfuscator: SecretObfuscator | undefined;
1111
1127
  #checkpointState: CheckpointState | undefined = undefined;
1112
1128
  #pendingRewindReport: string | undefined = undefined;
1113
1129
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1114
- #emptyStopRetryCount = 0;
1115
- #promptGeneration = 0;
1116
1130
  #providerSessionState = new Map<string, ProviderSessionState>();
1117
1131
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1118
1132
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -1617,8 +1631,34 @@ export class AgentSession {
1617
1631
  // Track last assistant message for auto-compaction check
1618
1632
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
1619
1633
 
1620
- /** Internal handler for agent events - shared by subscribe and reconnect */
1634
+ /** Internal handler for agent events - shared by subscribe and reconnect.
1635
+ *
1636
+ * `agent_end` handling schedules deferred post-prompt recovery work
1637
+ * (compaction/handoff, context-promotion continuations). It is invoked
1638
+ * fire-and-forget by the agent's synchronous `#emit`, and only reaches
1639
+ * `#checkCompaction` after several internal awaits. `prompt()` runs
1640
+ * `#waitForPostPromptRecovery()` the instant `agent.prompt()` resolves — which
1641
+ * can land BEFORE the handler registers its tasks, so the wait would observe an
1642
+ * empty task set and return early, letting a deferred handoff/promotion race
1643
+ * prompt completion. Tracking the `agent_end` handler as a post-prompt task
1644
+ * that is registered SYNCHRONOUSLY (before the first await) closes that window:
1645
+ * `#postPromptTasksPromise` is set the moment `#emit` invokes this handler, so
1646
+ * the recovery wait always sees the in-flight handler and blocks until it — and
1647
+ * everything it schedules — settles. */
1621
1648
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
1649
+ if (event.type !== "agent_end") {
1650
+ return this.#processAgentEvent(event);
1651
+ }
1652
+ const { promise, resolve } = Promise.withResolvers<void>();
1653
+ this.#trackPostPromptTask(promise);
1654
+ try {
1655
+ await this.#processAgentEvent(event);
1656
+ } finally {
1657
+ resolve();
1658
+ }
1659
+ };
1660
+
1661
+ #processAgentEvent = async (event: AgentEvent): Promise<void> => {
1622
1662
  // Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
1623
1663
  // persisted message BEFORE the obfuscator's display-side copy below.
1624
1664
  // Invariant (must hold across refactors): this branch precedes the
@@ -1785,6 +1825,14 @@ export class AgentSession {
1785
1825
  if (event.message.role === "assistant") {
1786
1826
  this.#lastAssistantMessage = event.message;
1787
1827
  const assistantMsg = event.message as AssistantMessage;
1828
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
1829
+ this.#lastProviderUsageNonMessage = {
1830
+ provider: assistantMsg.provider,
1831
+ model: assistantMsg.model,
1832
+ timestamp: assistantMsg.timestamp,
1833
+ tokens: this.#pendingProviderRequestNonMessageTokens ?? computeNonMessageTokens(this),
1834
+ };
1835
+ }
1788
1836
  const currentGrantsAnthropicPriority =
1789
1837
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
1790
1838
  if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
@@ -1931,6 +1979,9 @@ export class AgentSession {
1931
1979
  if (await this.#handleEmptyAssistantStop(msg)) {
1932
1980
  return;
1933
1981
  }
1982
+ if (await this.#handleUnexpectedAssistantStop(msg)) {
1983
+ return;
1984
+ }
1934
1985
 
1935
1986
  // A deliberate abort should settle the current turn, not trigger queued continuations.
1936
1987
  if (msg.stopReason === "aborted") {
@@ -4763,6 +4814,7 @@ export class AgentSession {
4763
4814
  this.#todoReminderCount = 0;
4764
4815
  this.#todoReminderAwaitingProgress = false;
4765
4816
  this.#emptyStopRetryCount = 0;
4817
+ this.#unexpectedStopRetryCount = 0;
4766
4818
 
4767
4819
  await this.#maybeRestoreRetryFallbackPrimary();
4768
4820
 
@@ -4903,7 +4955,12 @@ export class AgentSession {
4903
4955
  }
4904
4956
 
4905
4957
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4906
- await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4958
+ this.#pendingProviderRequestNonMessageTokens = computeNonMessageTokens(this);
4959
+ try {
4960
+ await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4961
+ } finally {
4962
+ this.#pendingProviderRequestNonMessageTokens = undefined;
4963
+ }
4907
4964
  if (!options?.skipPostPromptRecoveryWait) {
4908
4965
  await this.#waitForPostPromptRecovery(generation);
4909
4966
  }
@@ -6423,6 +6480,37 @@ export class AgentSession {
6423
6480
  let tokensBefore: number;
6424
6481
  let details: unknown;
6425
6482
 
6483
+ // Snapcompact runs locally first; if its frame archive plus the kept
6484
+ // history still overflows the model window, fall back to an LLM summary
6485
+ // (far cheaper than ~FRAME_TOKEN_ESTIMATE per frame).
6486
+ let snapcompactResult: snapcompact.CompactionResult | undefined;
6487
+ if (snapcompactReady) {
6488
+ snapcompactResult = await snapcompact.compact(preparation, {
6489
+ convertToLlm,
6490
+ model: this.model,
6491
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
6492
+ // Providers with hard image caps (OpenRouter: 8) silently drop
6493
+ // frames past the cap — keep the archive within budget.
6494
+ maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
6495
+ });
6496
+ const ctxWindow = this.model?.contextWindow ?? 0;
6497
+ const budget =
6498
+ ctxWindow > 0
6499
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
6500
+ : Number.POSITIVE_INFINITY;
6501
+ if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
6502
+ logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
6503
+ model: this.model?.id,
6504
+ });
6505
+ this.emitNotice(
6506
+ "warning",
6507
+ "snapcompact could not bring the context under the limit — using an LLM summary instead",
6508
+ "compaction",
6509
+ );
6510
+ snapcompactResult = undefined;
6511
+ }
6512
+ }
6513
+
6426
6514
  if (compactionPrep.kind === "fromHook") {
6427
6515
  summary = compactionPrep.summary;
6428
6516
  shortSummary = compactionPrep.shortSummary;
@@ -6430,15 +6518,7 @@ export class AgentSession {
6430
6518
  tokensBefore = compactionPrep.tokensBefore;
6431
6519
  details = compactionPrep.details;
6432
6520
  preserveData = compactionPrep.preserveData;
6433
- } else if (snapcompactReady) {
6434
- const snapcompactResult = await snapcompact.compact(preparation, {
6435
- convertToLlm,
6436
- model: this.model,
6437
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
6438
- // Providers with hard image caps (OpenRouter: 8) silently drop
6439
- // frames past the cap — keep the archive within budget.
6440
- maxFrames: snapcompact.providerFrameBudget(this.model.provider),
6441
- });
6521
+ } else if (snapcompactResult) {
6442
6522
  summary = snapcompactResult.summary;
6443
6523
  shortSummary = snapcompactResult.shortSummary;
6444
6524
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -6746,15 +6826,49 @@ export class AgentSession {
6746
6826
  return tokens;
6747
6827
  }
6748
6828
 
6829
+ #estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
6830
+ const currentUsage = this.getContextUsage({ contextWindow });
6831
+ if (typeof currentUsage?.tokens !== "number" || !Number.isFinite(currentUsage.tokens)) {
6832
+ return this.#estimatePendingPromptTokens(messages);
6833
+ }
6834
+
6835
+ const currentEstimate = this.#estimateContextTokens();
6836
+ if (!currentEstimate.providerAnchored) {
6837
+ return this.#estimatePendingPromptTokens(messages);
6838
+ }
6839
+
6840
+ let tokens = currentUsage.tokens;
6841
+ if (currentEstimate.providerNonMessageTokens !== undefined) {
6842
+ tokens += Math.max(0, computeNonMessageTokens(this) - currentEstimate.providerNonMessageTokens);
6843
+ }
6844
+ for (const message of messages) {
6845
+ tokens += estimateTokens(message);
6846
+ }
6847
+ return tokens;
6848
+ }
6849
+
6749
6850
  async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
6750
6851
  const model = this.model;
6751
6852
  if (!model) return;
6752
6853
  const contextWindow = model.contextWindow ?? 0;
6753
6854
  if (contextWindow <= 0) return;
6754
6855
  const compactionSettings = this.settings.getGroup("compaction");
6755
- const contextTokens = this.#estimatePendingPromptTokens(messages);
6856
+ const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
6756
6857
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
6757
6858
 
6859
+ // Auto-promote first: switching to a larger-context model avoids compacting
6860
+ // the history at all. The post-turn threshold path already promotes before
6861
+ // compacting; without this, the pre-prompt path would pre-empt promotion and
6862
+ // compact (snapcompact/summary) a session that should have just been promoted.
6863
+ if (await this.#promoteContextModel()) {
6864
+ logger.debug("Pre-prompt context promotion avoided compaction", {
6865
+ contextTokens,
6866
+ contextWindow,
6867
+ model: `${model.provider}/${model.id}`,
6868
+ });
6869
+ return;
6870
+ }
6871
+
6758
6872
  logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
6759
6873
  contextTokens,
6760
6874
  contextWindow,
@@ -6989,6 +7103,71 @@ export class AgentSession {
6989
7103
  maxRetries: EMPTY_STOP_MAX_RETRIES,
6990
7104
  });
6991
7105
  }
7106
+ async #handleUnexpectedAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
7107
+ if (!this.settings.get("features.unexpectedStopDetection")) {
7108
+ return false;
7109
+ }
7110
+ if (!isUnexpectedStopCandidate(assistantMessage)) {
7111
+ this.#unexpectedStopRetryCount = 0;
7112
+ return false;
7113
+ }
7114
+
7115
+ const text = assistantMessage.content
7116
+ .filter((content): content is TextContent => content.type === "text")
7117
+ .map(content => content.text)
7118
+ .join("\n");
7119
+ if (!/\S/.test(text)) {
7120
+ this.#unexpectedStopRetryCount = 0;
7121
+ return false;
7122
+ }
7123
+
7124
+ const controller = new AbortController();
7125
+ const timeout = setTimeout(() => controller.abort(), UNEXPECTED_STOP_TIMEOUT_MS);
7126
+ let classification: boolean | undefined;
7127
+ try {
7128
+ classification = await classifyUnexpectedStop(text, {
7129
+ settings: this.settings,
7130
+ registry: this.#modelRegistry,
7131
+ sessionId: this.sessionId,
7132
+ metadataResolver: (provider: string) => this.agent.metadataForProvider(provider),
7133
+ signal: controller.signal,
7134
+ });
7135
+ } finally {
7136
+ clearTimeout(timeout);
7137
+ }
7138
+
7139
+ if (classification !== true) {
7140
+ this.#unexpectedStopRetryCount = 0;
7141
+ return false;
7142
+ }
7143
+
7144
+ this.#unexpectedStopRetryCount++;
7145
+ if (this.#unexpectedStopRetryCount > UNEXPECTED_STOP_MAX_RETRIES) {
7146
+ logger.warn("Assistant returned unexpected stop after retry cap", {
7147
+ attempts: this.#unexpectedStopRetryCount - 1,
7148
+ model: assistantMessage.model,
7149
+ provider: assistantMessage.provider,
7150
+ });
7151
+ this.#unexpectedStopRetryCount = 0;
7152
+ return false;
7153
+ }
7154
+
7155
+ this.agent.appendMessage({
7156
+ role: "developer",
7157
+ content: [{ type: "text", text: this.#unexpectedStopRetryReminder() }],
7158
+ attribution: "agent",
7159
+ timestamp: Date.now(),
7160
+ });
7161
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
7162
+ return true;
7163
+ }
7164
+
7165
+ #unexpectedStopRetryReminder(): string {
7166
+ return prompt.render(unexpectedStopRetryTemplate, {
7167
+ retryCount: this.#unexpectedStopRetryCount,
7168
+ maxRetries: UNEXPECTED_STOP_MAX_RETRIES,
7169
+ });
7170
+ }
6992
7171
 
6993
7172
  #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
6994
7173
  const messages = this.agent.state.messages;
@@ -7324,12 +7503,28 @@ export class AgentSession {
7324
7503
  * Returns true if promotion succeeded (caller should retry without compacting).
7325
7504
  */
7326
7505
  async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
7327
- const promotionSettings = this.settings.getGroup("contextPromotion");
7328
- if (!promotionSettings.enabled) return false;
7329
7506
  const currentModel = this.model;
7330
7507
  if (!currentModel) return false;
7508
+ // The overflow/length error may have come from a model the user already
7509
+ // switched away from; only promote when the failing turn was this model.
7331
7510
  if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
7332
7511
  return false;
7512
+ return this.#promoteContextModel();
7513
+ }
7514
+
7515
+ /**
7516
+ * Switch to a larger-context sibling when context promotion is enabled and a
7517
+ * target with a strictly larger window (and a usable key) exists. Returns true
7518
+ * when the model was switched, so the caller can retry without compacting.
7519
+ * Message-independent core shared by the post-turn overflow path
7520
+ * ({@link #tryContextPromotion}) and the pre-prompt threshold path
7521
+ * ({@link #runPrePromptCompactionIfNeeded}).
7522
+ */
7523
+ async #promoteContextModel(): Promise<boolean> {
7524
+ const promotionSettings = this.settings.getGroup("contextPromotion");
7525
+ if (!promotionSettings.enabled) return false;
7526
+ const currentModel = this.model;
7527
+ if (!currentModel) return false;
7333
7528
  const contextWindow = currentModel.contextWindow ?? 0;
7334
7529
  if (contextWindow <= 0) return false;
7335
7530
  const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
@@ -7806,6 +8001,32 @@ export class AgentSession {
7806
8001
  return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
7807
8002
  }
7808
8003
 
8004
+ /**
8005
+ * Project the post-compaction context size of a snapcompact result: kept
8006
+ * recent messages + the summary message with its re-attached frames + the
8007
+ * fixed non-message overhead (system prompt + tools). Mirrors how the
8008
+ * compacted context is rebuilt, so the estimate matches the wire shape, and
8009
+ * lets the caller decide whether snapcompact brought the context under the
8010
+ * window or should fall back to an LLM summary.
8011
+ */
8012
+ #projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
8013
+ const archive = snapcompact.getPreservedArchive(result.preserveData);
8014
+ const frames = archive ? snapcompact.images(archive) : undefined;
8015
+ const summaryMessage = createCompactionSummaryMessage(
8016
+ result.summary,
8017
+ result.tokensBefore,
8018
+ new Date().toISOString(),
8019
+ result.shortSummary,
8020
+ undefined,
8021
+ frames,
8022
+ );
8023
+ let tokens = computeNonMessageTokens(this) + estimateTokens(summaryMessage);
8024
+ for (const message of preparation.recentMessages) {
8025
+ tokens += estimateTokens(message);
8026
+ }
8027
+ return tokens;
8028
+ }
8029
+
7809
8030
  /**
7810
8031
  * Internal: Run auto-compaction with events.
7811
8032
  *
@@ -8018,6 +8239,39 @@ export class AgentSession {
8018
8239
  let tokensBefore: number;
8019
8240
  let details: unknown;
8020
8241
 
8242
+ // Snapcompact runs locally first; if its frame archive plus the kept
8243
+ // history still overflows the model window (frames are capped by the
8244
+ // image budget and cost ~FRAME_TOKEN_ESTIMATE each), an LLM summary is
8245
+ // far cheaper — downgrade to context-full and take the summarizer path.
8246
+ let snapcompactResult: snapcompact.CompactionResult | undefined;
8247
+ if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
8248
+ snapcompactResult = await snapcompact.compact(preparation, {
8249
+ convertToLlm,
8250
+ model: this.model,
8251
+ maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
8252
+ });
8253
+ const ctxWindow = this.model?.contextWindow ?? 0;
8254
+ const budget =
8255
+ ctxWindow > 0
8256
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
8257
+ : Number.POSITIVE_INFINITY;
8258
+ const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
8259
+ if (projected > budget) {
8260
+ logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
8261
+ model: this.model?.id,
8262
+ projected,
8263
+ budget,
8264
+ });
8265
+ this.emitNotice(
8266
+ "warning",
8267
+ "snapcompact could not bring the context under the limit — using an LLM summary instead",
8268
+ "compaction",
8269
+ );
8270
+ action = "context-full";
8271
+ snapcompactResult = undefined;
8272
+ }
8273
+ }
8274
+
8021
8275
  if (compactionPrep.kind === "fromHook") {
8022
8276
  summary = compactionPrep.summary;
8023
8277
  shortSummary = compactionPrep.shortSummary;
@@ -8025,14 +8279,7 @@ export class AgentSession {
8025
8279
  tokensBefore = compactionPrep.tokensBefore;
8026
8280
  details = compactionPrep.details;
8027
8281
  preserveData = compactionPrep.preserveData;
8028
- } else if (action === "snapcompact") {
8029
- // Local, deterministic: render discarded history onto PNG frames.
8030
- // No model candidates, no API key, no retry loop.
8031
- const snapcompactResult = await snapcompact.compact(preparation, {
8032
- convertToLlm,
8033
- model: this.model,
8034
- maxFrames: snapcompact.providerFrameBudget(this.model?.provider),
8035
- });
8282
+ } else if (snapcompactResult) {
8036
8283
  summary = snapcompactResult.summary;
8037
8284
  shortSummary = snapcompactResult.shortSummary;
8038
8285
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -10422,6 +10669,8 @@ export class AgentSession {
10422
10669
  */
10423
10670
  #estimateContextTokens(): {
10424
10671
  tokens: number;
10672
+ providerAnchored: boolean;
10673
+ providerNonMessageTokens?: number;
10425
10674
  } {
10426
10675
  const messages = this.messages;
10427
10676
 
@@ -10448,10 +10697,19 @@ export class AgentSession {
10448
10697
  }
10449
10698
  return {
10450
10699
  tokens: estimated,
10700
+ providerAnchored: false,
10451
10701
  };
10452
10702
  }
10453
10703
 
10454
10704
  const usageTokens = calculatePromptTokens(lastUsage);
10705
+ const providerNonMessage =
10706
+ this.#lastProviderUsageNonMessage &&
10707
+ messages[lastUsageIndex]?.role === "assistant" &&
10708
+ this.#lastProviderUsageNonMessage.provider === (messages[lastUsageIndex] as AssistantMessage).provider &&
10709
+ this.#lastProviderUsageNonMessage.model === (messages[lastUsageIndex] as AssistantMessage).model &&
10710
+ this.#lastProviderUsageNonMessage.timestamp === (messages[lastUsageIndex] as AssistantMessage).timestamp
10711
+ ? this.#lastProviderUsageNonMessage.tokens
10712
+ : undefined;
10455
10713
  let trailingTokens = 0;
10456
10714
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
10457
10715
  trailingTokens += estimateTokens(messages[i]);
@@ -10459,6 +10717,8 @@ export class AgentSession {
10459
10717
 
10460
10718
  return {
10461
10719
  tokens: usageTokens + trailingTokens,
10720
+ providerAnchored: true,
10721
+ providerNonMessageTokens: providerNonMessage,
10462
10722
  };
10463
10723
  }
10464
10724
 
@@ -3,9 +3,10 @@
3
3
  */
4
4
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
- import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
7
- import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
8
- import { getVisibleThinkingText } from "../utils/thinking-display";
6
+ import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
7
+ import { getInbandGrammar, renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
8
+ import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
9
+ import { canonicalizeMessage } from "../utils/thinking-display";
9
10
  import {
10
11
  type BashExecutionMessage,
11
12
  type BranchSummaryMessage,
@@ -23,6 +24,7 @@ export interface SessionDumpToolInfo {
23
24
  name: string;
24
25
  description: string;
25
26
  parameters: unknown;
27
+ examples?: readonly ToolExample[];
26
28
  }
27
29
 
28
30
  export interface FormatSessionDumpTextOptions {
@@ -33,44 +35,12 @@ export interface FormatSessionDumpTextOptions {
33
35
  tools?: readonly SessionDumpToolInfo[];
34
36
  }
35
37
 
36
- function stripTypeBoxFields(obj: unknown): unknown {
37
- if (Array.isArray(obj)) {
38
- return obj.map(stripTypeBoxFields);
39
- }
40
- if (obj && typeof obj === "object") {
41
- const result: Record<string, unknown> = {};
42
- for (const [k, v] of Object.entries(obj)) {
43
- if (!k.startsWith("TypeBox.")) {
44
- result[k] = stripTypeBoxFields(v);
45
- }
46
- }
47
- return result;
48
- }
49
- return obj;
50
- }
51
-
52
- /** Resolve tool parameters to a plain JSON Schema object for dump output. */
53
- function toolParametersToJsonSchema(parameters: unknown): unknown {
54
- if (isZodSchema(parameters)) return zodToWireSchema(parameters);
55
- return stripTypeBoxFields(parameters);
56
- }
57
-
58
- /** Serialize an object as XML parameter elements, one per key. */
59
- function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
60
- const parts: string[] = [];
61
- for (const [key, value] of Object.entries(args)) {
62
- if (key === INTENT_FIELD) continue;
63
- const text = typeof value === "string" ? value : JSON.stringify(value);
64
- parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
65
- }
66
- return parts.join("\n");
67
- }
68
-
69
38
  /**
70
39
  * Format messages and session metadata as markdown/plain text (same as AgentSession.formatSessionAsText / /dump).
71
40
  */
72
41
  export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
73
42
  const lines: string[] = [];
43
+ const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
74
44
 
75
45
  const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
76
46
  if (systemPrompt.length > 0) {
@@ -94,13 +64,13 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
94
64
  const tools = options.tools ?? [];
95
65
  if (tools.length > 0) {
96
66
  lines.push("## Available Tools\n");
97
- for (const tool of tools) {
98
- lines.push(`<tool name="${tool.name}">`);
99
- lines.push(tool.description);
100
- const parametersClean = toolParametersToJsonSchema(tool.parameters);
101
- lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
102
- lines.push("<" + "/tool>\n");
103
- }
67
+ const inventoryTools = tools.map(tool => ({
68
+ name: tool.name,
69
+ description: tool.description,
70
+ parameters: tool.parameters as TSchema,
71
+ examples: tool.examples,
72
+ }));
73
+ lines.push(renderToolInventory(inventoryTools, options.model?.id ?? ""));
104
74
  lines.push("\n");
105
75
  }
106
76
 
@@ -127,17 +97,15 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
127
97
  if (c.type === "text") {
128
98
  lines.push(c.text);
129
99
  } else if (c.type === "thinking") {
130
- const thinking = getVisibleThinkingText(c);
100
+ const thinking = canonicalizeMessage(c.thinking);
131
101
  if (thinking.length === 0) continue;
132
102
  lines.push("<thinking>");
133
103
  lines.push(thinking);
134
104
  lines.push("</thinking>\n");
135
105
  } else if (c.type === "toolCall") {
136
- lines.push(`<invoke name="${c.name}">`);
137
- if (c.arguments && typeof c.arguments === "object") {
138
- lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
139
- }
140
- lines.push("<" + "/invoke>\n");
106
+ const args = { ...(c.arguments as Record<string, unknown>) };
107
+ delete args[INTENT_FIELD];
108
+ lines.push(grammar.renderToolCall({ ...c, arguments: args }));
141
109
  }
142
110
  }
143
111
  lines.push("");