@oh-my-pi/pi-coding-agent 15.13.2 → 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 +22 -0
- package/dist/cli.js +147 -122
- package/dist/types/config/settings-schema.d.ts +31 -0
- package/dist/types/eval/js/context-manager.d.ts +15 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
- package/dist/types/stt/asr-client.d.ts +1 -1
- package/dist/types/tiny/title-client.d.ts +1 -1
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/tts/tts-client.d.ts +1 -1
- 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 +6 -2
- package/src/config/settings-schema.ts +25 -0
- package/src/eval/__tests__/agent-bridge.test.ts +106 -46
- package/src/eval/__tests__/js-context-manager.test.ts +12 -2
- package/src/eval/js/context-manager.ts +40 -3
- 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 +5 -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/controllers/event-controller.ts +3 -3
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/controllers/streaming-reveal.ts +4 -4
- package/src/modes/interactive-mode.ts +2 -0
- 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/unexpected-stop-classifier.md +17 -0
- package/src/prompts/system/unexpected-stop-retry.md +4 -0
- package/src/session/agent-session.ts +164 -10
- package/src/session/session-dump-format.ts +8 -19
- package/src/session/unexpected-stop-classifier.ts +129 -0
- package/src/stt/asr-client.ts +1 -1
- package/src/tiny/title-client.ts +1 -1
- package/src/tools/browser/tab-supervisor.ts +1 -1
- package/src/tools/browser/tab-worker-entry.ts +12 -4
- package/src/tools/job.ts +1 -0
- package/src/tts/tts-client.ts +1 -1
- package/src/utils/thinking-display.ts +8 -34
|
@@ -202,6 +202,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
|
|
|
202
202
|
};
|
|
203
203
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
204
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" };
|
|
205
206
|
import {
|
|
206
207
|
deobfuscateSessionContext,
|
|
207
208
|
obfuscateProviderContext,
|
|
@@ -270,6 +271,7 @@ import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
|
|
|
270
271
|
import type { SessionManager } from "./session-manager";
|
|
271
272
|
import type { ShakeMode, ShakeResult } from "./shake-types";
|
|
272
273
|
import { ToolChoiceQueue } from "./tool-choice-queue";
|
|
274
|
+
import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
|
|
273
275
|
import { YieldQueue } from "./yield-queue";
|
|
274
276
|
|
|
275
277
|
/** Session-specific events that extend the core AgentEvent */
|
|
@@ -308,14 +310,16 @@ export type AgentSessionEvent =
|
|
|
308
310
|
resolved?: Effort;
|
|
309
311
|
}
|
|
310
312
|
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
|
|
311
|
-
|
|
312
313
|
/** Listener function for agent session events */
|
|
313
314
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
314
|
-
export type CommandMetadataChangedListener = () => void | Promise<void>;
|
|
315
|
-
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
316
315
|
|
|
316
|
+
const UNEXPECTED_STOP_MAX_RETRIES = 3;
|
|
317
|
+
const UNEXPECTED_STOP_TIMEOUT_MS = 4000;
|
|
317
318
|
const EMPTY_STOP_MAX_RETRIES = 3;
|
|
318
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
|
+
|
|
319
323
|
const RETRY_BACKOFF_JITTER_RATIO = 0.25;
|
|
320
324
|
/**
|
|
321
325
|
* Hysteresis band for the post-shake "did we actually create headroom?" check.
|
|
@@ -1106,15 +1110,23 @@ export class AgentSession {
|
|
|
1106
1110
|
// `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
|
|
1107
1111
|
// Cursor exec, TUI listeners) is held back. Without this, a client that resumes
|
|
1108
1112
|
// on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
|
|
1109
|
-
|
|
1110
|
-
|
|
1113
|
+
#emptyStopRetryCount = 0;
|
|
1114
|
+
#unexpectedStopRetryCount = 0;
|
|
1115
|
+
#promptGeneration = 0;
|
|
1111
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;
|
|
1112
1126
|
#obfuscator: SecretObfuscator | undefined;
|
|
1113
1127
|
#checkpointState: CheckpointState | undefined = undefined;
|
|
1114
1128
|
#pendingRewindReport: string | undefined = undefined;
|
|
1115
1129
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
1116
|
-
#emptyStopRetryCount = 0;
|
|
1117
|
-
#promptGeneration = 0;
|
|
1118
1130
|
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
1119
1131
|
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
|
1120
1132
|
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
|
@@ -1619,8 +1631,34 @@ export class AgentSession {
|
|
|
1619
1631
|
// Track last assistant message for auto-compaction check
|
|
1620
1632
|
#lastAssistantMessage: AssistantMessage | undefined = undefined;
|
|
1621
1633
|
|
|
1622
|
-
/** 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. */
|
|
1623
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> => {
|
|
1624
1662
|
// Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
|
|
1625
1663
|
// persisted message BEFORE the obfuscator's display-side copy below.
|
|
1626
1664
|
// Invariant (must hold across refactors): this branch precedes the
|
|
@@ -1787,6 +1825,14 @@ export class AgentSession {
|
|
|
1787
1825
|
if (event.message.role === "assistant") {
|
|
1788
1826
|
this.#lastAssistantMessage = event.message;
|
|
1789
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
|
+
}
|
|
1790
1836
|
const currentGrantsAnthropicPriority =
|
|
1791
1837
|
this.serviceTier === "priority" || this.serviceTier === "claude-only";
|
|
1792
1838
|
if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
|
|
@@ -1933,6 +1979,9 @@ export class AgentSession {
|
|
|
1933
1979
|
if (await this.#handleEmptyAssistantStop(msg)) {
|
|
1934
1980
|
return;
|
|
1935
1981
|
}
|
|
1982
|
+
if (await this.#handleUnexpectedAssistantStop(msg)) {
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1936
1985
|
|
|
1937
1986
|
// A deliberate abort should settle the current turn, not trigger queued continuations.
|
|
1938
1987
|
if (msg.stopReason === "aborted") {
|
|
@@ -4765,6 +4814,7 @@ export class AgentSession {
|
|
|
4765
4814
|
this.#todoReminderCount = 0;
|
|
4766
4815
|
this.#todoReminderAwaitingProgress = false;
|
|
4767
4816
|
this.#emptyStopRetryCount = 0;
|
|
4817
|
+
this.#unexpectedStopRetryCount = 0;
|
|
4768
4818
|
|
|
4769
4819
|
await this.#maybeRestoreRetryFallbackPrimary();
|
|
4770
4820
|
|
|
@@ -4905,7 +4955,12 @@ export class AgentSession {
|
|
|
4905
4955
|
}
|
|
4906
4956
|
|
|
4907
4957
|
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
|
|
4908
|
-
|
|
4958
|
+
this.#pendingProviderRequestNonMessageTokens = computeNonMessageTokens(this);
|
|
4959
|
+
try {
|
|
4960
|
+
await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
|
|
4961
|
+
} finally {
|
|
4962
|
+
this.#pendingProviderRequestNonMessageTokens = undefined;
|
|
4963
|
+
}
|
|
4909
4964
|
if (!options?.skipPostPromptRecoveryWait) {
|
|
4910
4965
|
await this.#waitForPostPromptRecovery(generation);
|
|
4911
4966
|
}
|
|
@@ -6771,13 +6826,34 @@ export class AgentSession {
|
|
|
6771
6826
|
return tokens;
|
|
6772
6827
|
}
|
|
6773
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
|
+
|
|
6774
6850
|
async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
|
|
6775
6851
|
const model = this.model;
|
|
6776
6852
|
if (!model) return;
|
|
6777
6853
|
const contextWindow = model.contextWindow ?? 0;
|
|
6778
6854
|
if (contextWindow <= 0) return;
|
|
6779
6855
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
6780
|
-
const contextTokens = this.#
|
|
6856
|
+
const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
|
|
6781
6857
|
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
|
6782
6858
|
|
|
6783
6859
|
// Auto-promote first: switching to a larger-context model avoids compacting
|
|
@@ -7027,6 +7103,71 @@ export class AgentSession {
|
|
|
7027
7103
|
maxRetries: EMPTY_STOP_MAX_RETRIES,
|
|
7028
7104
|
});
|
|
7029
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
|
+
}
|
|
7030
7171
|
|
|
7031
7172
|
#removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
|
|
7032
7173
|
const messages = this.agent.state.messages;
|
|
@@ -10528,6 +10669,8 @@ export class AgentSession {
|
|
|
10528
10669
|
*/
|
|
10529
10670
|
#estimateContextTokens(): {
|
|
10530
10671
|
tokens: number;
|
|
10672
|
+
providerAnchored: boolean;
|
|
10673
|
+
providerNonMessageTokens?: number;
|
|
10531
10674
|
} {
|
|
10532
10675
|
const messages = this.messages;
|
|
10533
10676
|
|
|
@@ -10554,10 +10697,19 @@ export class AgentSession {
|
|
|
10554
10697
|
}
|
|
10555
10698
|
return {
|
|
10556
10699
|
tokens: estimated,
|
|
10700
|
+
providerAnchored: false,
|
|
10557
10701
|
};
|
|
10558
10702
|
}
|
|
10559
10703
|
|
|
10560
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;
|
|
10561
10713
|
let trailingTokens = 0;
|
|
10562
10714
|
for (let i = lastUsageIndex + 1; i < messages.length; i++) {
|
|
10563
10715
|
trailingTokens += estimateTokens(messages[i]);
|
|
@@ -10565,6 +10717,8 @@ export class AgentSession {
|
|
|
10565
10717
|
|
|
10566
10718
|
return {
|
|
10567
10719
|
tokens: usageTokens + trailingTokens,
|
|
10720
|
+
providerAnchored: true,
|
|
10721
|
+
providerNonMessageTokens: providerNonMessage,
|
|
10568
10722
|
};
|
|
10569
10723
|
}
|
|
10570
10724
|
|
|
@@ -4,8 +4,9 @@
|
|
|
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
6
|
import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
|
7
|
-
import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
|
|
8
|
-
import {
|
|
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,
|
|
@@ -34,22 +35,12 @@ export interface FormatSessionDumpTextOptions {
|
|
|
34
35
|
tools?: readonly SessionDumpToolInfo[];
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/** Serialize an object as XML parameter elements, one per key. */
|
|
38
|
-
function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
|
|
39
|
-
const parts: string[] = [];
|
|
40
|
-
for (const [key, value] of Object.entries(args)) {
|
|
41
|
-
if (key === INTENT_FIELD) continue;
|
|
42
|
-
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
43
|
-
parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
|
|
44
|
-
}
|
|
45
|
-
return parts.join("\n");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
38
|
/**
|
|
49
39
|
* Format messages and session metadata as markdown/plain text (same as AgentSession.formatSessionAsText / /dump).
|
|
50
40
|
*/
|
|
51
41
|
export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
|
|
52
42
|
const lines: string[] = [];
|
|
43
|
+
const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
|
|
53
44
|
|
|
54
45
|
const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
|
|
55
46
|
if (systemPrompt.length > 0) {
|
|
@@ -106,17 +97,15 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
|
|
|
106
97
|
if (c.type === "text") {
|
|
107
98
|
lines.push(c.text);
|
|
108
99
|
} else if (c.type === "thinking") {
|
|
109
|
-
const thinking =
|
|
100
|
+
const thinking = canonicalizeMessage(c.thinking);
|
|
110
101
|
if (thinking.length === 0) continue;
|
|
111
102
|
lines.push("<thinking>");
|
|
112
103
|
lines.push(thinking);
|
|
113
104
|
lines.push("</thinking>\n");
|
|
114
105
|
} else if (c.type === "toolCall") {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
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 }));
|
|
120
109
|
}
|
|
121
110
|
}
|
|
122
111
|
lines.push("");
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
3
|
+
|
|
4
|
+
import type { ModelRegistry } from "../config/model-registry";
|
|
5
|
+
import { resolveRoleSelection } from "../config/model-resolver";
|
|
6
|
+
import type { Settings } from "../config/settings";
|
|
7
|
+
import unexpectedStopClassifierPrompt from "../prompts/system/unexpected-stop-classifier.md" with { type: "text" };
|
|
8
|
+
import { isTinyMemoryLocalModelKey, ONLINE_MEMORY_MODEL_KEY } from "../tiny/models";
|
|
9
|
+
import { tinyModelClient } from "../tiny/title-client";
|
|
10
|
+
|
|
11
|
+
const CLASSIFIER_SYSTEM_PROMPT = prompt.render(unexpectedStopClassifierPrompt);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The answer is a single word. OpenAI-compatible endpoints reject values below
|
|
15
|
+
* 16, so 16 is the smallest portable budget for this classifier.
|
|
16
|
+
*/
|
|
17
|
+
const ANSWER_MAX_TOKENS = 16;
|
|
18
|
+
/**
|
|
19
|
+
* Reasoning backends ignore `disableReasoning` on some providers, so reserve
|
|
20
|
+
* enough output room for the keyword to still land after unavoidable thinking.
|
|
21
|
+
*/
|
|
22
|
+
const REASONING_SAFE_MAX_TOKENS = 1024;
|
|
23
|
+
|
|
24
|
+
export interface ClassifyUnexpectedStopDeps {
|
|
25
|
+
settings: Settings;
|
|
26
|
+
registry: ModelRegistry;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isUnexpectedStopCandidate(message: AssistantMessage): boolean {
|
|
33
|
+
if (message.stopReason !== "stop") return false;
|
|
34
|
+
let hasText = false;
|
|
35
|
+
for (const content of message.content) {
|
|
36
|
+
if (content.type === "toolCall") return false;
|
|
37
|
+
if (content.type === "text" && /\S/.test(content.text)) {
|
|
38
|
+
hasText = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return hasText;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function classifyUnexpectedStop(
|
|
45
|
+
text: string,
|
|
46
|
+
deps: ClassifyUnexpectedStopDeps,
|
|
47
|
+
): Promise<boolean | undefined> {
|
|
48
|
+
const backend = deps.settings.get("providers.unexpectedStopModel");
|
|
49
|
+
try {
|
|
50
|
+
if (backend === ONLINE_MEMORY_MODEL_KEY) {
|
|
51
|
+
return await classifyOnline(text, deps);
|
|
52
|
+
}
|
|
53
|
+
if (isTinyMemoryLocalModelKey(backend)) {
|
|
54
|
+
return await classifyLocal(text, backend, deps);
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.debug("unexpected-stop: classification failed", {
|
|
59
|
+
error: error instanceof Error ? error.message : String(error),
|
|
60
|
+
backend,
|
|
61
|
+
});
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
|
|
67
|
+
const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
|
|
68
|
+
const model = resolved?.model;
|
|
69
|
+
if (!model) {
|
|
70
|
+
throw new Error("unexpected-stop: no smol model available for classification");
|
|
71
|
+
}
|
|
72
|
+
const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
|
|
73
|
+
if (!apiKey) {
|
|
74
|
+
throw new Error(`unexpected-stop: no API key for ${model.provider}/${model.id}`);
|
|
75
|
+
}
|
|
76
|
+
const metadata = deps.metadataResolver?.(model.provider);
|
|
77
|
+
const maxTokens = model.reasoning ? Math.max(ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : ANSWER_MAX_TOKENS;
|
|
78
|
+
|
|
79
|
+
const response = await completeSimple(
|
|
80
|
+
model,
|
|
81
|
+
{
|
|
82
|
+
systemPrompt: [CLASSIFIER_SYSTEM_PROMPT],
|
|
83
|
+
messages: [{ role: "user", content: text, timestamp: Date.now() }],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
apiKey: deps.registry.resolver(model, deps.sessionId),
|
|
87
|
+
maxTokens,
|
|
88
|
+
disableReasoning: true,
|
|
89
|
+
metadata,
|
|
90
|
+
signal: deps.signal,
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (response.stopReason === "error") {
|
|
95
|
+
throw new Error(`unexpected-stop: online classification failed: ${response.errorMessage ?? "unknown error"}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const outputText = response.content
|
|
99
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
100
|
+
.map(part => part.text)
|
|
101
|
+
.join("\n");
|
|
102
|
+
return parseUnexpectedStopClassification(outputText);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function classifyLocal(
|
|
106
|
+
text: string,
|
|
107
|
+
modelKey: string,
|
|
108
|
+
deps: ClassifyUnexpectedStopDeps,
|
|
109
|
+
): Promise<boolean | undefined> {
|
|
110
|
+
if (!isTinyMemoryLocalModelKey(modelKey)) {
|
|
111
|
+
throw new Error(`unexpected-stop: unsupported local classifier model: ${modelKey}`);
|
|
112
|
+
}
|
|
113
|
+
const builtPrompt = prompt.render(unexpectedStopClassifierPrompt, { message: text });
|
|
114
|
+
const output = await tinyModelClient.complete(modelKey, builtPrompt, {
|
|
115
|
+
maxTokens: ANSWER_MAX_TOKENS,
|
|
116
|
+
signal: deps.signal,
|
|
117
|
+
});
|
|
118
|
+
if (!output) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return parseUnexpectedStopClassification(output);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseUnexpectedStopClassification(text: string): boolean | undefined {
|
|
125
|
+
const trimmed = text.trim().toLowerCase();
|
|
126
|
+
if (trimmed.startsWith("yes")) return true;
|
|
127
|
+
if (trimmed.startsWith("no")) return false;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
package/src/stt/asr-client.ts
CHANGED
|
@@ -72,7 +72,7 @@ const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
|
72
72
|
* Hidden subcommand on the main CLI that boots the speech-recognition worker in
|
|
73
73
|
* the spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
|
|
74
74
|
*/
|
|
75
|
-
export const STT_WORKER_ARG = "
|
|
75
|
+
export const STT_WORKER_ARG = "__omp_worker_stt";
|
|
76
76
|
|
|
77
77
|
function readTinyModelSetting(key: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
|
|
78
78
|
try {
|
package/src/tiny/title-client.ts
CHANGED
|
@@ -69,7 +69,7 @@ function normalizeTinyTitleGenerateOptions(
|
|
|
69
69
|
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
|
|
70
70
|
* spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
|
|
71
71
|
*/
|
|
72
|
-
export const TINY_WORKER_ARG = "
|
|
72
|
+
export const TINY_WORKER_ARG = "__omp_worker_tiny_inference";
|
|
73
73
|
|
|
74
74
|
function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
|
|
75
75
|
try {
|
|
@@ -685,7 +685,7 @@ async function spawnTabWorker(): Promise<WorkerHandle> {
|
|
|
685
685
|
try {
|
|
686
686
|
const hostEntry = workerHostEntry();
|
|
687
687
|
const worker = hostEntry
|
|
688
|
-
? new Worker(hostEntry, { type: "module", argv: ["
|
|
688
|
+
? new Worker(hostEntry, { type: "module", argv: ["__omp_worker_tab"] })
|
|
689
689
|
: new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
|
|
690
690
|
return wrapBunWorker(worker);
|
|
691
691
|
} catch (err) {
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
|
|
2
3
|
import type { Transport, WorkerInbound, WorkerOutbound } from "./tab-protocol";
|
|
3
4
|
import { WorkerCore } from "./tab-worker";
|
|
4
5
|
|
|
5
6
|
if (!parentPort) throw new Error("tab-worker-entry: missing parentPort");
|
|
6
7
|
|
|
8
|
+
const port = parentPort;
|
|
9
|
+
// When the CLI host pre-buffered messages (it imports this module dynamically),
|
|
10
|
+
// bind that inbox so the parent's already-delivered `init` is replayed. Loaded
|
|
11
|
+
// directly (test/SDK fallback), this module's top-level runs synchronously at
|
|
12
|
+
// worker start, so the direct `parentPort.on` below wins the flush on its own.
|
|
13
|
+
const inbox = consumeWorkerInbox();
|
|
7
14
|
const transport: Transport = {
|
|
8
15
|
send(msg, transferList) {
|
|
9
|
-
|
|
16
|
+
port.postMessage(msg, transferList ?? []);
|
|
10
17
|
},
|
|
11
18
|
onMessage(handler) {
|
|
19
|
+
if (inbox) return inbox.bind(data => handler(data as WorkerOutbound | WorkerInbound));
|
|
12
20
|
const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
|
|
13
|
-
|
|
14
|
-
return () =>
|
|
21
|
+
port.on("message", wrap);
|
|
22
|
+
return () => port.off("message", wrap);
|
|
15
23
|
},
|
|
16
24
|
close() {
|
|
17
|
-
|
|
25
|
+
port.close();
|
|
18
26
|
},
|
|
19
27
|
};
|
|
20
28
|
|
package/src/tools/job.ts
CHANGED
|
@@ -87,6 +87,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
|
|
|
87
87
|
readonly description: string;
|
|
88
88
|
readonly parameters = jobSchema;
|
|
89
89
|
readonly strict = true;
|
|
90
|
+
readonly interruptible = true;
|
|
90
91
|
readonly loadMode = "discoverable";
|
|
91
92
|
constructor(private readonly session: ToolSession) {
|
|
92
93
|
this.description = prompt.render(jobDescription);
|
package/src/tts/tts-client.ts
CHANGED
|
@@ -142,7 +142,7 @@ const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
|
142
142
|
* Hidden subcommand on the main CLI that boots the TTS worker in the spawned
|
|
143
143
|
* subprocess. Kept in sync with the dispatch in `cli.ts` (Main-owned).
|
|
144
144
|
*/
|
|
145
|
-
export const TTS_WORKER_ARG = "
|
|
145
|
+
export const TTS_WORKER_ARG = "__omp_worker_tts";
|
|
146
146
|
|
|
147
147
|
function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
|
|
148
148
|
try {
|
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
for (let i = 0; i < text.length; i++) {
|
|
9
|
-
const code = text.charCodeAt(i);
|
|
10
|
-
if (code === 0x2e || code === 0x2026) {
|
|
11
|
-
sawDot = true;
|
|
12
|
-
continue;
|
|
1
|
+
export function canonicalizeMessage(text: string | null | undefined): string {
|
|
2
|
+
if (!text) return "";
|
|
3
|
+
const trimmed = text.trim();
|
|
4
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
5
|
+
const code = trimmed.charCodeAt(i);
|
|
6
|
+
if (code !== 0x2e && code !== 0x2026 && code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
|
|
7
|
+
return trimmed;
|
|
13
8
|
}
|
|
14
|
-
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
|
|
15
|
-
return false;
|
|
16
9
|
}
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Returns the operator-visible thinking text for a block.
|
|
22
|
-
*
|
|
23
|
-
* Some OpenAI-compatible reasoning gateways require a non-empty
|
|
24
|
-
* `reasoning_content` field on historical assistant tool-call turns even when
|
|
25
|
-
* the model did not emit any reasoning. The provider adapter uses a single dot
|
|
26
|
-
* as the wire-only placeholder those gateways accept; if that value is later
|
|
27
|
-
* replayed or echoed as a thinking block, it should not render as model thought.
|
|
28
|
-
*/
|
|
29
|
-
export function getVisibleThinkingText(block: ThinkingBlock): string {
|
|
30
|
-
const text = block.thinking.trim();
|
|
31
|
-
if (text.length === 0) return "";
|
|
32
|
-
return isDotOnlyThinking(text) ? "" : text;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function hasVisibleThinking(block: ThinkingBlock): boolean {
|
|
36
|
-
return getVisibleThinkingText(block).length > 0;
|
|
10
|
+
return "";
|
|
37
11
|
}
|