@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.
- package/CHANGELOG.md +47 -0
- package/dist/cli.js +1057 -289
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/models-config.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +97 -0
- package/dist/types/edit/hashline/block-resolver.d.ts +1 -1
- package/dist/types/edit/index.d.ts +2 -0
- package/dist/types/eval/js/context-manager.d.ts +15 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/input-controller.d.ts +4 -4
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +2 -1
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk.d.ts +3 -0
- package/dist/types/session/session-dump-format.d.ts +2 -1
- package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
- package/dist/types/stt/asr-client.d.ts +1 -1
- package/dist/types/system-prompt.d.ts +11 -0
- package/dist/types/tiny/title-client.d.ts +1 -1
- package/dist/types/tools/ask.d.ts +2 -0
- package/dist/types/tools/ast-edit.d.ts +2 -0
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +2 -0
- package/dist/types/tools/find.d.ts +2 -0
- package/dist/types/tools/inspect-image.d.ts +2 -1
- package/dist/types/tools/irc.d.ts +2 -0
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo.d.ts +2 -0
- package/dist/types/tts/tts-client.d.ts +1 -1
- package/dist/types/tui/tree-list.d.ts +1 -0
- package/dist/types/utils/thinking-display.d.ts +1 -17
- package/package.json +12 -12
- package/src/cli.ts +25 -12
- package/src/config/model-registry.ts +16 -2
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/models-config.ts +1 -0
- package/src/config/settings-schema.ts +78 -0
- package/src/edit/hashline/block-resolver.ts +1 -1
- package/src/edit/hashline/execute.ts +1 -6
- package/src/edit/index.ts +48 -0
- package/src/eval/__tests__/agent-bridge.test.ts +106 -46
- package/src/eval/__tests__/js-context-manager.test.ts +53 -3
- package/src/eval/js/context-manager.ts +132 -29
- package/src/eval/js/worker-core.ts +1 -1
- package/src/eval/js/worker-entry.ts +7 -0
- package/src/export/html/template.js +18 -22
- package/src/internal-urls/docs-index.generated.ts +12 -3
- package/src/main.ts +15 -5
- package/src/modes/acp/acp-agent.ts +2 -2
- package/src/modes/acp/acp-event-mapper.ts +2 -2
- package/src/modes/components/agent-hub.ts +31 -7
- package/src/modes/components/assistant-message.ts +24 -15
- package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
- package/src/modes/components/snapcompact-shape-preview.ts +2 -2
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/welcome.ts +14 -4
- package/src/modes/controllers/event-controller.ts +3 -3
- package/src/modes/controllers/input-controller.ts +28 -39
- package/src/modes/controllers/streaming-reveal.ts +4 -4
- package/src/modes/interactive-mode.ts +2 -0
- package/src/modes/rpc/rpc-mode.ts +1 -0
- package/src/modes/rpc/rpc-types.ts +2 -2
- package/src/modes/types.ts +6 -0
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/prompts/agents/oracle.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/system/system-prompt.md +17 -21
- package/src/prompts/system/unexpected-stop-classifier.md +17 -0
- package/src/prompts/system/unexpected-stop-retry.md +4 -0
- package/src/prompts/tools/ask.md +0 -8
- package/src/prompts/tools/ast-edit.md +0 -15
- package/src/prompts/tools/ast-grep.md +0 -13
- package/src/prompts/tools/browser.md +0 -21
- package/src/prompts/tools/debug.md +0 -13
- package/src/prompts/tools/eval.md +0 -9
- package/src/prompts/tools/find.md +0 -13
- package/src/prompts/tools/inspect-image.md +0 -9
- package/src/prompts/tools/irc.md +0 -15
- package/src/prompts/tools/patch.md +0 -13
- package/src/prompts/tools/ssh.md +0 -9
- package/src/prompts/tools/todo.md +1 -19
- package/src/sdk.ts +19 -0
- package/src/session/agent-session.ts +289 -29
- package/src/session/session-dump-format.ts +17 -49
- package/src/session/unexpected-stop-classifier.ts +129 -0
- package/src/stt/asr-client.ts +1 -1
- package/src/system-prompt.ts +31 -0
- package/src/tiny/title-client.ts +1 -1
- package/src/tools/ask.ts +41 -0
- package/src/tools/ast-edit.ts +46 -0
- package/src/tools/ast-grep.ts +24 -0
- package/src/tools/browser/tab-supervisor.ts +1 -1
- package/src/tools/browser/tab-worker-entry.ts +12 -4
- package/src/tools/browser.ts +52 -0
- package/src/tools/debug.ts +17 -0
- package/src/tools/eval.ts +20 -1
- package/src/tools/find.ts +24 -0
- package/src/tools/inspect-image.ts +27 -1
- package/src/tools/irc.ts +41 -0
- package/src/tools/job.ts +1 -0
- package/src/tools/ssh.ts +16 -0
- package/src/tools/todo.ts +82 -3
- package/src/tts/tts-client.ts +1 -1
- package/src/tui/tree-list.ts +68 -19
- 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
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
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 (
|
|
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.#
|
|
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 (
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 =
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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("");
|