super-dsh 0.1.0 → 0.1.2
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/agent-agy/bridge/agy_bridge.py +250 -0
- package/agent-agy/bridge/gemini_openai_server.mjs +98 -0
- package/agent-agy/cordis.patch.yml +49 -0
- package/agent-agy/dist/adapter.d.ts +15 -0
- package/agent-agy/dist/adapter.js +42 -0
- package/agent-agy/dist/agent-preset-agy.d.ts +47 -0
- package/agent-agy/dist/agent-preset-agy.js +169 -0
- package/agent-agy/dist/agent-preset-projection.d.ts +31 -0
- package/agent-agy/dist/agent-preset-projection.js +33 -0
- package/agent-agy/dist/agent.d.ts +137 -0
- package/agent-agy/dist/agent.js +493 -0
- package/agent-agy/dist/agy-cli-client.d.ts +137 -0
- package/agent-agy/dist/agy-cli-client.js +301 -0
- package/agent-agy/dist/agy-client.d.ts +160 -0
- package/agent-agy/dist/agy-client.js +252 -0
- package/agent-agy/dist/agy-sessions.d.ts +24 -0
- package/agent-agy/dist/agy-sessions.js +116 -0
- package/agent-agy/dist/agy-store.d.ts +39 -0
- package/agent-agy/dist/agy-store.js +83 -0
- package/agent-agy/dist/inbox.d.ts +26 -0
- package/agent-agy/dist/inbox.js +53 -0
- package/agent-agy/dist/index.d.ts +80 -0
- package/agent-agy/dist/index.js +505 -0
- package/agent-agy/dist/models.d.ts +41 -0
- package/agent-agy/dist/models.js +74 -0
- package/agent-agy/dist/world-plugin.d.ts +9 -0
- package/agent-agy/dist/world-plugin.js +97 -0
- package/agent-agy/package.json +64 -0
- package/agent-claude/cordis.patch.yml +42 -0
- package/agent-claude/dist/adapter.js +62 -0
- package/agent-claude/dist/agent-preset-claude.js +169 -0
- package/agent-claude/dist/agent-preset-projection.js +30 -0
- package/agent-claude/dist/agent.js +1271 -0
- package/agent-claude/dist/claude-client.js +357 -0
- package/agent-claude/dist/claude-events.js +223 -0
- package/agent-claude/dist/claude-home.js +29 -0
- package/agent-claude/dist/commands.js +182 -0
- package/agent-claude/dist/content.js +134 -0
- package/agent-claude/dist/inbox.js +53 -0
- package/agent-claude/dist/index.js +555 -0
- package/agent-claude/dist/input-queue.js +64 -0
- package/agent-claude/dist/interaction.js +271 -0
- package/agent-claude/dist/models.js +158 -0
- package/agent-claude/dist/permission.js +120 -0
- package/agent-claude/dist/session-id.js +20 -0
- package/agent-claude/dist/session-map.js +165 -0
- package/agent-claude/dist/world-plugin.js +14 -0
- package/agent-claude/package.json +74 -0
- package/agent-codex/README.md +74 -0
- package/agent-codex/cordis.patch.yml +77 -0
- package/agent-codex/dist/adapter.js +55 -0
- package/agent-codex/dist/agent-preset-codex.js +171 -0
- package/agent-codex/dist/agent-preset-projection.js +37 -0
- package/agent-codex/dist/agent.js +1056 -0
- package/agent-codex/dist/app-home.js +65 -0
- package/agent-codex/dist/codex-client.js +469 -0
- package/agent-codex/dist/codex-events.js +297 -0
- package/agent-codex/dist/codex-store.js +190 -0
- package/agent-codex/dist/inbox.js +53 -0
- package/agent-codex/dist/index.js +605 -0
- package/agent-codex/dist/models.js +152 -0
- package/agent-codex/dist/permission.js +71 -0
- package/agent-codex/dist/session-map.js +170 -0
- package/agent-codex/dist/spike.js +46 -0
- package/agent-codex/dist/world-plugin.js +96 -0
- package/agent-codex/package.json +78 -0
- package/agent-hermes/README.md +144 -0
- package/agent-hermes/cordis.patch.yml +83 -0
- package/agent-hermes/dist/adapter.js +83 -0
- package/agent-hermes/dist/agent-preset-hermes.js +171 -0
- package/agent-hermes/dist/agent-preset-projection.js +34 -0
- package/agent-hermes/dist/agent.js +1068 -0
- package/agent-hermes/dist/hermes-client.js +953 -0
- package/agent-hermes/dist/hermes-events.js +289 -0
- package/agent-hermes/dist/hermes-store.js +158 -0
- package/agent-hermes/dist/inbox.js +53 -0
- package/agent-hermes/dist/index.js +541 -0
- package/agent-hermes/dist/models.js +324 -0
- package/agent-hermes/dist/permission.js +60 -0
- package/agent-hermes/dist/world-plugin.js +101 -0
- package/agent-hermes/package.json +76 -0
- package/agent-hub/cordis.patch.yml +12 -0
- package/agent-hub/dist/agent-roster.d.ts +34 -0
- package/agent-hub/dist/agent-roster.js +27 -0
- package/agent-hub/dist/carrier.d.ts +43 -0
- package/agent-hub/dist/carrier.js +241 -0
- package/agent-hub/dist/client-shim.d.ts +28 -0
- package/agent-hub/dist/client-shim.js +251 -0
- package/agent-hub/dist/envelope.d.ts +54 -0
- package/agent-hub/dist/envelope.js +92 -0
- package/agent-hub/dist/gateway.d.ts +28 -0
- package/agent-hub/dist/gateway.js +18 -0
- package/agent-hub/dist/index-pass.d.ts +22 -0
- package/agent-hub/dist/index-pass.js +47 -0
- package/agent-hub/dist/index.d.ts +29 -0
- package/agent-hub/dist/index.js +29 -0
- package/agent-hub/dist/labels.d.ts +45 -0
- package/agent-hub/dist/labels.js +83 -0
- package/agent-hub/dist/ownership.d.ts +37 -0
- package/agent-hub/dist/ownership.js +54 -0
- package/agent-hub/dist/roster.d.ts +28 -0
- package/agent-hub/dist/roster.js +38 -0
- package/agent-hub/dist/rpc.d.ts +38 -0
- package/agent-hub/dist/rpc.js +52 -0
- package/agent-hub/dist/spawn-world.d.ts +46 -0
- package/agent-hub/dist/spawn-world.js +72 -0
- package/agent-hub/dist/targets.d.ts +33 -0
- package/agent-hub/dist/targets.js +30 -0
- package/agent-hub/dist/world-entry.d.ts +31 -0
- package/agent-hub/dist/world-entry.js +51 -0
- package/agent-hub/dist/world-host.d.ts +69 -0
- package/agent-hub/dist/world-host.js +44 -0
- package/agent-hub/dist/world-join.d.ts +16 -0
- package/agent-hub/dist/world-join.js +122 -0
- package/agent-hub/dist/world-mount.d.ts +52 -0
- package/agent-hub/dist/world-mount.js +52 -0
- package/agent-hub/dist/world-mux.d.ts +72 -0
- package/agent-hub/dist/world-mux.js +224 -0
- package/agent-hub/dist/world-provision.d.ts +20 -0
- package/agent-hub/dist/world-provision.js +122 -0
- package/agent-hub/dist/world-web-server.d.ts +162 -0
- package/agent-hub/dist/world-web-server.js +248 -0
- package/agent-hub/lib/client/index.js +242 -0
- package/agent-hub/package.json +65 -0
- package/agent-omp/README.md +77 -0
- package/agent-omp/cordis.patch.yml +92 -0
- package/agent-omp/dist/adapter.js +36 -0
- package/agent-omp/dist/agent-preset-omp.js +171 -0
- package/agent-omp/dist/agent-preset-projection.js +36 -0
- package/agent-omp/dist/agent.js +1145 -0
- package/agent-omp/dist/app-home.js +86 -0
- package/agent-omp/dist/discovery.js +116 -0
- package/agent-omp/dist/inbox.js +53 -0
- package/agent-omp/dist/index.js +574 -0
- package/agent-omp/dist/knobs.js +50 -0
- package/agent-omp/dist/lazy-rpc.js +161 -0
- package/agent-omp/dist/mobile/gesture.js +148 -0
- package/agent-omp/dist/mobile/zoom-guard.js +294 -0
- package/agent-omp/dist/mobile-boot.js +38 -0
- package/agent-omp/dist/models.js +299 -0
- package/agent-omp/dist/omp-cli.js +147 -0
- package/agent-omp/dist/omp-disk-discovery.js +178 -0
- package/agent-omp/dist/omp-store.js +518 -0
- package/agent-omp/dist/pairing.js +45 -0
- package/agent-omp/dist/permission.js +96 -0
- package/agent-omp/dist/protocol.js +13 -0
- package/agent-omp/dist/rpc-types.js +1 -0
- package/agent-omp/dist/sdk-client.js +378 -0
- package/agent-omp/dist/sidecar-client.js +195 -0
- package/agent-omp/dist/store/adapter.js +47 -0
- package/agent-omp/dist/store/db.js +210 -0
- package/agent-omp/dist/store/index.js +59 -0
- package/agent-omp/dist/store/reconcile.js +51 -0
- package/agent-omp/dist/store/schema.js +40 -0
- package/agent-omp/dist/world-plugin.js +96 -0
- package/agent-omp/package.json +97 -0
- package/agent-omp/sidecar/main.ts +481 -0
- package/agent-pi/README.md +67 -0
- package/agent-pi/cordis.patch.yml +80 -0
- package/agent-pi/dist/adapter.js +53 -0
- package/agent-pi/dist/agent-preset-pi.js +169 -0
- package/agent-pi/dist/agent-preset-projection.js +29 -0
- package/agent-pi/dist/agent.js +949 -0
- package/agent-pi/dist/inbox.js +53 -0
- package/agent-pi/dist/index.js +595 -0
- package/agent-pi/dist/knobs.js +24 -0
- package/agent-pi/dist/models.js +200 -0
- package/agent-pi/dist/permission.js +66 -0
- package/agent-pi/dist/pi-client.js +224 -0
- package/agent-pi/dist/pi-events.js +155 -0
- package/agent-pi/dist/pi-home.js +55 -0
- package/agent-pi/dist/safe-json.js +25 -0
- package/agent-pi/dist/session-map.js +119 -0
- package/agent-pi/dist/world-plugin.js +97 -0
- package/agent-pi/package.json +77 -0
- package/cordis.patch.yml +22 -19
- package/package.json +88 -25
- package/client.js +0 -4
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
import { agentEvents } from "@deepseek-ai/dsh-agent";
|
|
2
|
+
import { Inbox } from "./inbox.js";
|
|
3
|
+
import { LlmAttemptId, ToolCallId, QUOTA_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
import { createScope } from "@deepseek-ai/dsh-scope";
|
|
5
|
+
import { getBridgeStore } from "./store/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* v2 live assistant-stream publication for the OMP bridge — the same frame
|
|
8
|
+
* protocol the reference loop's AssistantStreamAttempt speaks (start marker,
|
|
9
|
+
* dense zero-based chunks, terminal settlement) emitted over this agent's own
|
|
10
|
+
* dispatch, so the session-controller folds OMP's stream into its reconnect
|
|
11
|
+
* baseline. Chunks also accumulate as raw durable records embedded in the
|
|
12
|
+
* final assistant/message.
|
|
13
|
+
*/
|
|
14
|
+
class AssistantStreamBridge {
|
|
15
|
+
attemptId;
|
|
16
|
+
records = [];
|
|
17
|
+
#nextRevision;
|
|
18
|
+
#emit;
|
|
19
|
+
#index = 0;
|
|
20
|
+
#terminal = false;
|
|
21
|
+
constructor(sessionId, attempt, nextRevision, emit) {
|
|
22
|
+
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
|
|
23
|
+
this.#nextRevision = nextRevision;
|
|
24
|
+
this.#emit = emit;
|
|
25
|
+
}
|
|
26
|
+
/** Whether the terminal frame already fired. */
|
|
27
|
+
get ended() {
|
|
28
|
+
return this.#terminal;
|
|
29
|
+
}
|
|
30
|
+
/** Opening marker before the first delivered chunk. */
|
|
31
|
+
start(turn, step) {
|
|
32
|
+
this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
|
|
33
|
+
}
|
|
34
|
+
/** One live chunk: durable record plus dense process-local frame. */
|
|
35
|
+
push(chunk) {
|
|
36
|
+
const time = Date.now();
|
|
37
|
+
this.records.push({ type: "chunk", time, chunk });
|
|
38
|
+
this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
|
|
39
|
+
}
|
|
40
|
+
/** Terminal settlement after the durable assistant/message committed. */
|
|
41
|
+
settle(seq) {
|
|
42
|
+
this.#terminal = true;
|
|
43
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
|
|
44
|
+
}
|
|
45
|
+
/** No durable attempt event will commit. */
|
|
46
|
+
abandon() {
|
|
47
|
+
this.#terminal = true;
|
|
48
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Diagnostic trace (set OMP_TRACE=1 on the dsh process to enable). */
|
|
52
|
+
const TRACE = process.env.OMP_TRACE === "1";
|
|
53
|
+
const trace = (...parts) => {
|
|
54
|
+
if (TRACE)
|
|
55
|
+
process.stderr.write(`[omp-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
|
|
56
|
+
};
|
|
57
|
+
export function convertContent(blocks) {
|
|
58
|
+
if (blocks === undefined)
|
|
59
|
+
return [];
|
|
60
|
+
const result = [];
|
|
61
|
+
for (const block of blocks) {
|
|
62
|
+
switch (block.type) {
|
|
63
|
+
case "text":
|
|
64
|
+
if (typeof block.text === "string")
|
|
65
|
+
result.push({ type: "text", text: block.text });
|
|
66
|
+
break;
|
|
67
|
+
case "thinking":
|
|
68
|
+
if (typeof block.thinking === "string")
|
|
69
|
+
result.push({ type: "reasoning", text: block.thinking });
|
|
70
|
+
break;
|
|
71
|
+
case "toolCall": {
|
|
72
|
+
const id = typeof block.id === "string" ? block.id : "";
|
|
73
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
74
|
+
const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
|
|
75
|
+
result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
// images / unknown blocks are not bridged in Phase 2.
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Classify OMP's `stopReason: "error"` assistant message into a Dash `failure`
|
|
87
|
+
* (`{ message, code }`). This is the same taxonomy the harness adapters use for
|
|
88
|
+
* `turn/end` reasons — `AUTH`, `RATE_LIMIT`, `QUOTA`, `SERVER` — so the Web UI
|
|
89
|
+
* renders it through its own error path (the red "API key is invalid" alert,
|
|
90
|
+
* turn-positioned failure feedback) instead of an empty assistant bubble.
|
|
91
|
+
* Quota/usage wording is checked before the generic 403→AUTH because OMP's
|
|
92
|
+
* providers (e.g. kimi) report an exhausted billing quota as HTTP 403.
|
|
93
|
+
*/
|
|
94
|
+
export function ompFailure(message) {
|
|
95
|
+
if (message.stopReason !== "error")
|
|
96
|
+
return undefined;
|
|
97
|
+
const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
|
|
98
|
+
const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
|
|
99
|
+
? message.errorMessage.trim()
|
|
100
|
+
: undefined;
|
|
101
|
+
const detail = text ?? "";
|
|
102
|
+
let code;
|
|
103
|
+
if (/\b(?:usage|rate)\s*limit\b/i.test(detail) || /\bquota\b/i.test(detail) || /\bbilling\b/i.test(detail) || /\binsufficient\b/i.test(detail)) {
|
|
104
|
+
code = QUOTA_EXCEEDED_CODE;
|
|
105
|
+
}
|
|
106
|
+
else if (status === 401 || status === 403) {
|
|
107
|
+
code = "AUTH";
|
|
108
|
+
}
|
|
109
|
+
else if (status === 429) {
|
|
110
|
+
code = "RATE_LIMIT";
|
|
111
|
+
}
|
|
112
|
+
else if (status !== undefined && status >= 500) {
|
|
113
|
+
code = "SERVER";
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
code = "UNKNOWN";
|
|
117
|
+
}
|
|
118
|
+
return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
|
|
119
|
+
}
|
|
120
|
+
/** Map OMP's usage accounting into Dash `TokenUsage` (disjoint field names). */
|
|
121
|
+
export function convertUsage(usage) {
|
|
122
|
+
if (usage === null || typeof usage !== "object")
|
|
123
|
+
return undefined;
|
|
124
|
+
const u = usage;
|
|
125
|
+
const inputTokens = typeof u.input === "number" ? u.input : 0;
|
|
126
|
+
const outputTokens = typeof u.output === "number" ? u.output : 0;
|
|
127
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
128
|
+
return undefined;
|
|
129
|
+
const result = { inputTokens, outputTokens };
|
|
130
|
+
if (typeof u.cacheRead === "number")
|
|
131
|
+
result.cacheReadTokens = u.cacheRead;
|
|
132
|
+
if (typeof u.cacheWrite === "number")
|
|
133
|
+
result.cacheWriteTokens = u.cacheWrite;
|
|
134
|
+
if (typeof u.reasoningTokens === "number")
|
|
135
|
+
result.reasoningTokens = u.reasoningTokens;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/** Join the visible text blocks of a Dash user message into the OMP prompt string. */
|
|
139
|
+
function userMessageText(message) {
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const block of message.content) {
|
|
142
|
+
if (block.type === "text")
|
|
143
|
+
parts.push(block.text);
|
|
144
|
+
}
|
|
145
|
+
return parts.join("\n");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Dash-host plugin narration from the approval service: the policy-change
|
|
149
|
+
* notice `agent.inject` carries when `/permission` switches a live session's
|
|
150
|
+
* approval policy. OMP has no runtime approval control (its `--approval-mode`
|
|
151
|
+
* is pinned at launch, with no RPC to change it mid-session), so forwarding
|
|
152
|
+
* the text would steer it into an in-flight turn (interrupting tool work) and
|
|
153
|
+
* echo it into history as a fake user prompt. Detect and drop it.
|
|
154
|
+
*/
|
|
155
|
+
function isApprovalNarration(message) {
|
|
156
|
+
const source = message.source;
|
|
157
|
+
return source.kind === "plugin" && source.plugin === "user-approval";
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Reconstruct a Dash user message from an OMP `message_start(role=user)`
|
|
161
|
+
* payload. Used only as a defensive fallback when a queued delivery's retained
|
|
162
|
+
* message is missing (OMP echoes text, so the visible content is preserved).
|
|
163
|
+
*/
|
|
164
|
+
function userMessageFromOmp(message) {
|
|
165
|
+
return createUserMessage({
|
|
166
|
+
content: convertContent(message.content).filter((block) => block.type === "text"),
|
|
167
|
+
source: { kind: "user" },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Extract every tool call an OMP assistant message names, in order, for approval correlation. */
|
|
171
|
+
function toolCalls(blocks) {
|
|
172
|
+
if (blocks === undefined)
|
|
173
|
+
return [];
|
|
174
|
+
const result = [];
|
|
175
|
+
for (const block of blocks) {
|
|
176
|
+
if (block.type === "toolCall" && typeof block.id === "string") {
|
|
177
|
+
result.push({ callId: block.id, name: typeof block.name === "string" ? block.name : "" });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
/** Parse the tool name out of an OMP approval-card title (`Allow tool: <name>…`). */
|
|
183
|
+
function approvalToolName(title) {
|
|
184
|
+
const match = /Allow tool:\s*(\S+)/.exec(title);
|
|
185
|
+
return match?.[1] ?? "tool";
|
|
186
|
+
}
|
|
187
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
/**
|
|
189
|
+
* Approval answer timeout: fail closed (Deny) after this long so a headless or
|
|
190
|
+
* server run can't hang forever waiting on a card nobody answers. `0` disables
|
|
191
|
+
* the timeout. Default: 5 minutes.
|
|
192
|
+
*/
|
|
193
|
+
const OMP_IDLE_EXIT_MS = parseIdleExit(process.env.OMP_IDLE_EXIT_MS);
|
|
194
|
+
/**
|
|
195
|
+
* How long an agent may sit idle before its SDK sidecar session is torn down.
|
|
196
|
+
* The Dash host keeps resumed agents registered forever (nothing else disposes
|
|
197
|
+
* them), and every idle agent pins a live sidecar session. Tearing the agent
|
|
198
|
+
* down at idle costs nothing observable — the next prompt re-attaches it from
|
|
199
|
+
* the dsh session log (of record) plus the OMP app-home session file.
|
|
200
|
+
* `0` disables the exit.
|
|
201
|
+
*/
|
|
202
|
+
function parseIdleExit(raw) {
|
|
203
|
+
if (raw === undefined || raw.trim() === "")
|
|
204
|
+
return 600_000;
|
|
205
|
+
const value = Number(raw);
|
|
206
|
+
return Number.isFinite(value) && value >= 0 ? value : 600_000;
|
|
207
|
+
}
|
|
208
|
+
const OMP_APPROVAL_TIMEOUT_MS = parseApprovalTimeout(process.env.OMP_APPROVAL_TIMEOUT_MS);
|
|
209
|
+
function parseApprovalTimeout(raw) {
|
|
210
|
+
if (raw === undefined || raw.trim() === "")
|
|
211
|
+
return 300_000;
|
|
212
|
+
const value = Number(raw);
|
|
213
|
+
return Number.isFinite(value) && value >= 0 ? value : 300_000;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Race an approval request against the configured timeout. On timeout the
|
|
217
|
+
* signal aborts (withdrawing the question) and we fail closed as `"cancelled"`.
|
|
218
|
+
*/
|
|
219
|
+
async function raceApproval(request, controller, timeoutMs) {
|
|
220
|
+
if (timeoutMs <= 0)
|
|
221
|
+
return request;
|
|
222
|
+
let timer;
|
|
223
|
+
try {
|
|
224
|
+
return await Promise.race([
|
|
225
|
+
request,
|
|
226
|
+
new Promise((resolve) => {
|
|
227
|
+
timer = setTimeout(() => {
|
|
228
|
+
controller.abort();
|
|
229
|
+
resolve("cancelled");
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
}),
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export class OmpAgent {
|
|
239
|
+
id;
|
|
240
|
+
options;
|
|
241
|
+
session;
|
|
242
|
+
inbox;
|
|
243
|
+
ctx;
|
|
244
|
+
#rpc;
|
|
245
|
+
#loopCtx;
|
|
246
|
+
#scope;
|
|
247
|
+
#dispatch;
|
|
248
|
+
#streaming = false;
|
|
249
|
+
#activityDone = Promise.resolve();
|
|
250
|
+
#resolveActivityDone = () => { };
|
|
251
|
+
#dashTurn = 0;
|
|
252
|
+
#step = 0;
|
|
253
|
+
#lastTurn = 0;
|
|
254
|
+
#turnOpen = false;
|
|
255
|
+
#cancelCause = null;
|
|
256
|
+
/** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
|
|
257
|
+
#pendingFailure = null;
|
|
258
|
+
/** Live v2 assistant-stream publication for the in-flight OMP message. */
|
|
259
|
+
#streamBridge;
|
|
260
|
+
#assistantAttemptCounter = 0;
|
|
261
|
+
#assistantStreamRevision = 0;
|
|
262
|
+
/** The current turn's user/message was appended locally and OMP's echo is still awaited. */
|
|
263
|
+
#localUserPending = false;
|
|
264
|
+
/** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
|
|
265
|
+
#stepStartPending = false;
|
|
266
|
+
/**
|
|
267
|
+
* Deliveries forwarded through OMP's own queue, in submission order. OMP
|
|
268
|
+
* echoes each as `message_start(role=user)`; the bridge maps it by transport:
|
|
269
|
+
* a `followUp` echo closes the current Dash turn and opens a fresh one
|
|
270
|
+
* (OMP queues it and auto-generates the next turn — connection layer
|
|
271
|
+
* hardcodes `resumeIfIdle: true`), a `steer` echo appends a `user/message`
|
|
272
|
+
* step to the current turn. `sent` marks entries already dispatched to OMP
|
|
273
|
+
* (a parked injection flushes at agent_start).
|
|
274
|
+
*/
|
|
275
|
+
#remoteQueue = [];
|
|
276
|
+
#bridgedToolResults = new Set();
|
|
277
|
+
#pendingToolCalls = [];
|
|
278
|
+
#approvalAborts = new Set();
|
|
279
|
+
/** The last selection already synced to OMP via `set_model`, as `provider/model`. */
|
|
280
|
+
#lastSyncedModel = null;
|
|
281
|
+
/** Fires when the agent has been idle past OMP_IDLE_EXIT_MS (tears down the rpc child). */
|
|
282
|
+
#onIdleExit;
|
|
283
|
+
#idleExitTimer = undefined;
|
|
284
|
+
#disposed = false;
|
|
285
|
+
/** Last OMP-reported session title mirrored into this log (dedupe). */
|
|
286
|
+
#mirroredTitle;
|
|
287
|
+
/** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
|
|
288
|
+
#sessionIdentityCommitted = false;
|
|
289
|
+
constructor(loopCtx, id, options, session, rpc, onIdleExit) {
|
|
290
|
+
this.#loopCtx = loopCtx;
|
|
291
|
+
this.id = id;
|
|
292
|
+
this.options = options;
|
|
293
|
+
this.session = session;
|
|
294
|
+
this.#rpc = rpc;
|
|
295
|
+
this.#onIdleExit = onIdleExit;
|
|
296
|
+
this.#dispatch = agentEvents(loopCtx, this);
|
|
297
|
+
this.inbox = new Inbox();
|
|
298
|
+
this.#scope = createScope(loopCtx, this);
|
|
299
|
+
this.ctx = this.#scope.ctx.extend({ agent: this });
|
|
300
|
+
this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
301
|
+
rpc.on((event) => this.#handleEvent(event));
|
|
302
|
+
// OMP's own title arrives on its own frame (the session event stream carries
|
|
303
|
+
// no title event), so it is applied whenever OMP reports it.
|
|
304
|
+
rpc.onTitle((title) => this.#applyOmpTitle(title));
|
|
305
|
+
rpc.onFailure((error) => this.#fail(error));
|
|
306
|
+
// Session-identity events (the agent-preset stamp and the surface-node-0
|
|
307
|
+
// system prompt) are committed on the FIRST turn by
|
|
308
|
+
// #bootstrapSessionIdentity — only once the lazy child actually exists —
|
|
309
|
+
// so a freshly created session announces completely blank, exactly like
|
|
310
|
+
// the native agent-loop factory's: the backend takes zero part in the
|
|
311
|
+
// UI's browser-local new-session draft.
|
|
312
|
+
// `/permission` cannot work here: OMP pins `--approval-mode` at launch and
|
|
313
|
+
// no RPC changes it mid-session. Registering the SAME name on this agent's
|
|
314
|
+
// own scope (a command-injected child of `agent.ctx`) SHADOWS the global
|
|
315
|
+
// permission command for this agent only — the switch then fails cleanly
|
|
316
|
+
// instead of appending approval/policy events OMP cannot honor. (The
|
|
317
|
+
// `inject` narration drop below stays as the backstop.)
|
|
318
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
319
|
+
name: "permission",
|
|
320
|
+
description: "Unavailable for OMP sessions (approval policy is fixed at OMP launch)",
|
|
321
|
+
input: { hint: "<preset>" },
|
|
322
|
+
handler: () => ({
|
|
323
|
+
kind: "error",
|
|
324
|
+
text: "Approval policy is fixed at OMP launch; runtime switching is not supported for OMP sessions.",
|
|
325
|
+
}),
|
|
326
|
+
}));
|
|
327
|
+
// `/compact` parity (route 1): OMP owns context compaction — the Dash log
|
|
328
|
+
// is only the UI's scrollback projection, so no compaction/* events are
|
|
329
|
+
// fabricated and the surface is never replaced. Shadowing the loop-owned
|
|
330
|
+
// global name keeps the (disabled) native pipeline from receiving it; the
|
|
331
|
+
// command node renders the lifecycle and outcome natively.
|
|
332
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
333
|
+
name: "compact",
|
|
334
|
+
description: "Compact the OMP session context (OMP-native compaction)",
|
|
335
|
+
input: { hint: "[instructions]" },
|
|
336
|
+
handler: (invocation) => this.#handleCompactCommand(invocation),
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
get status() {
|
|
340
|
+
return this.#streaming ? "running" : "idle";
|
|
341
|
+
}
|
|
342
|
+
send(message, target, wakeup) {
|
|
343
|
+
this.#deliver(message, target, wakeup);
|
|
344
|
+
}
|
|
345
|
+
followup(message) {
|
|
346
|
+
// The Web UI's `session.prompt` (mode "queue") routes through followup.
|
|
347
|
+
// Idle → `prompt` (opens the Dash turn locally, before OMP's ~2s cold
|
|
348
|
+
// start); busy → `follow_up` (OMP's own queue: delivered as the next
|
|
349
|
+
// turn's user message and auto-generated). Mirrors dsh-agent-loop's
|
|
350
|
+
// `followup = send(input, "next-turn", true)`.
|
|
351
|
+
this.#deliver(message, "next-turn", true);
|
|
352
|
+
}
|
|
353
|
+
steer(message) {
|
|
354
|
+
// Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
|
|
355
|
+
// idle driver starts a turn; a running driver consumes it at the next step.
|
|
356
|
+
this.#deliver(message, "next-step", true);
|
|
357
|
+
}
|
|
358
|
+
inject(message) {
|
|
359
|
+
// Dash-host approval-policy narrations have no OMP runtime equivalent
|
|
360
|
+
// (OMP pins `--approval-mode` at launch; no RPC changes it mid-session).
|
|
361
|
+
// Forwarding the text would steer it into an in-flight turn — interrupting
|
|
362
|
+
// tool work — and echo it into history as a fake user prompt. Drop it; the
|
|
363
|
+
// accompanying `approval/policy` session event stays recorded so the UI's
|
|
364
|
+
// permission state remains truthful.
|
|
365
|
+
if (isApprovalNarration(message)) {
|
|
366
|
+
trace("inject: dropped approval-policy narration (OMP lacks runtime approval control)");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
|
|
370
|
+
// queued without waking; idle drivers leave it pending until a later
|
|
371
|
+
// follow-up/steer wakes them (flushed at the next agent_start).
|
|
372
|
+
this.#deliver(message, "next-step", false);
|
|
373
|
+
}
|
|
374
|
+
cancel(cause, _options) {
|
|
375
|
+
// No active run → no-op: arming `#cancelCause` here would misreport a later,
|
|
376
|
+
// unrelated completed turn as aborted. First cause wins: later cancels must
|
|
377
|
+
// not overwrite an earlier cause (dash contract).
|
|
378
|
+
if (!this.#streaming)
|
|
379
|
+
return;
|
|
380
|
+
if (this.#cancelCause === null)
|
|
381
|
+
this.#cancelCause = cause;
|
|
382
|
+
void this.#rpc.abort().catch(() => { });
|
|
383
|
+
}
|
|
384
|
+
async whenIdle() {
|
|
385
|
+
// Event-driven: wait for the current activity to settle (agent_end), then
|
|
386
|
+
// confirm OMP reports quiescence via get_state (the mapping's poll).
|
|
387
|
+
let activity;
|
|
388
|
+
do {
|
|
389
|
+
activity = this.#activityDone;
|
|
390
|
+
await activity;
|
|
391
|
+
} while (activity !== this.#activityDone);
|
|
392
|
+
for (;;) {
|
|
393
|
+
const state = await this.#rpc.getState().catch(() => null);
|
|
394
|
+
if (state === null || !state.isStreaming)
|
|
395
|
+
return;
|
|
396
|
+
await delay(50);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
runMaintenance(task) {
|
|
400
|
+
// OMP has no maintenance concept; run the task directly.
|
|
401
|
+
return task(new AbortController().signal);
|
|
402
|
+
}
|
|
403
|
+
/** Stop OMP and unwind the scoped world. Idempotent. */
|
|
404
|
+
async dispose() {
|
|
405
|
+
if (this.#disposed)
|
|
406
|
+
return;
|
|
407
|
+
this.#disposed = true;
|
|
408
|
+
clearTimeout(this.#idleExitTimer);
|
|
409
|
+
this.#idleExitTimer = undefined;
|
|
410
|
+
for (const controller of this.#approvalAborts)
|
|
411
|
+
controller.abort();
|
|
412
|
+
this.#approvalAborts.clear();
|
|
413
|
+
this.#rpc.close();
|
|
414
|
+
await this.#scope.dispose();
|
|
415
|
+
}
|
|
416
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
417
|
+
/**
|
|
418
|
+
* Route one message by the reference loop's (target, wakeup) semantics:
|
|
419
|
+
* an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
|
|
420
|
+
* sent while a turn is open is forwarded to OMP's own queue (`follow_up`:
|
|
421
|
+
* OMP delivers it as the next turn's user message and auto-generates that
|
|
422
|
+
* turn); steering/injection goes through `steer` (consumed at the next
|
|
423
|
+
* step, or parked until agent_start when not yet streaming).
|
|
424
|
+
*
|
|
425
|
+
* Busy is the LOCAL turn state (`#turnOpen`), not OMP's `#streaming` flag:
|
|
426
|
+
* OMP needs a moment (cold child start) to emit `agent_start` after a
|
|
427
|
+
* prompt, and in that gap a bare `prompt` would be rejected by OMP while
|
|
428
|
+
* `follow_up` is state-safe (it queues and resumes from either state).
|
|
429
|
+
*/
|
|
430
|
+
#deliver(message, target, wakeup) {
|
|
431
|
+
if (this.#disposed)
|
|
432
|
+
return;
|
|
433
|
+
const busy = this.#turnOpen || this.#streaming;
|
|
434
|
+
trace(`#deliver target=${target} wakeup=${wakeup} busy=${busy} (turnOpen=${this.#turnOpen} streaming=${this.#streaming})`);
|
|
435
|
+
if (busy && target === "next-turn") {
|
|
436
|
+
this.#queueRemote(message, "followUp");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (busy || !wakeup) {
|
|
440
|
+
this.#queueRemote(message, "steer");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.#deliverPrompt(message);
|
|
444
|
+
}
|
|
445
|
+
#deliverPrompt(message) {
|
|
446
|
+
if (this.#disposed)
|
|
447
|
+
return;
|
|
448
|
+
trace(`#deliverPrompt turn=${this.#dashTurn + 1} text="${userMessageText(message).slice(0, 40)}"`);
|
|
449
|
+
// Reserve the turn synchronously: busy-flag semantics stay identical to
|
|
450
|
+
// the eager line (a second delivery during the cold start queues
|
|
451
|
+
// remotely), but NOTHING is appended yet — the log stays empty until the
|
|
452
|
+
// lazy child exists, so a failed cold start leaves no orphan turn.
|
|
453
|
+
this.#reserveTurn();
|
|
454
|
+
clearTimeout(this.#idleExitTimer);
|
|
455
|
+
this.#idleExitTimer = undefined;
|
|
456
|
+
this.#beginActivity();
|
|
457
|
+
void this.#startTurn(message);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Cold-start path for one reserved turn: spawn the lazy child (first prompt
|
|
461
|
+
* only), commit the session-identity events ahead of the turn (surface node
|
|
462
|
+
* 0 = system prompt — the native loop's step() ordering), sync the model,
|
|
463
|
+
* then append turn/start + user/message and dispatch. A failed cold start
|
|
464
|
+
* synthesizes the failed turn (turn/end with the error reason) so the UI's
|
|
465
|
+
* error path renders it, then unwinds the reservation.
|
|
466
|
+
*/
|
|
467
|
+
async #startTurn(message) {
|
|
468
|
+
try {
|
|
469
|
+
await this.#rpc.ensureStarted();
|
|
470
|
+
await this.#bootstrapSessionIdentity();
|
|
471
|
+
await this.#syncModelSelection();
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
trace(`#startTurn cold start failed: ${String(error)}`);
|
|
475
|
+
if (this.#turnOpen) {
|
|
476
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
477
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
478
|
+
this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
|
|
479
|
+
}
|
|
480
|
+
this.#endActivity();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (this.#disposed)
|
|
484
|
+
return;
|
|
485
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
486
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
487
|
+
void this.#rpc.prompt(userMessageText(message)).catch((error) => this.#fail(error));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* One-shot first-turn identity commit, right after the lazy child spawns:
|
|
491
|
+
* the agent-preset stamp (idempotent) and the live system prompt as surface
|
|
492
|
+
* node 0. This is now the ONLY system-prompt emitter — cold replay emits
|
|
493
|
+
* none (the OMP JSONL never records a system prompt), so a resumed session
|
|
494
|
+
* gets the RPC prompt appended immediately before its first live
|
|
495
|
+
* `turn/start` (timeline-tail semantics), while a fresh session gets it as
|
|
496
|
+
* node 0 (that render IS turn-1). The guard — skip when the seed already
|
|
497
|
+
* carries a `system/message` — stays.
|
|
498
|
+
*/
|
|
499
|
+
async #bootstrapSessionIdentity() {
|
|
500
|
+
if (this.#sessionIdentityCommitted)
|
|
501
|
+
return;
|
|
502
|
+
this.#sessionIdentityCommitted = true;
|
|
503
|
+
if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "omp")) {
|
|
504
|
+
this.session.append("agent-preset/selected", { agentPreset: "omp" });
|
|
505
|
+
}
|
|
506
|
+
if (!this.session.snapshotEvents().some((event) => event.type === "system/message")) {
|
|
507
|
+
const systemPrompt = await this.#rpc.getSystemPrompt();
|
|
508
|
+
if (systemPrompt !== undefined && systemPrompt !== "") {
|
|
509
|
+
// dsh session v3 requires positive turn/step coordinates on surface
|
|
510
|
+
// events (`turn must be positive`); native fresh sessions use 1/1 for the
|
|
511
|
+
// node-0 system prompt. 0/0 made the native jsonl writer reject the whole
|
|
512
|
+
// live batch, stalling every append before turn/start.
|
|
513
|
+
this.session.append("system/message", { turn: 1, step: 1, message: createSystemMessage(systemPrompt, "omp-web") }, { surfaceOp: "append" });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* /compact handler (route 1): run OMP's own compaction on the live child
|
|
519
|
+
* and report its real metering as the command outcome. No compaction/*
|
|
520
|
+
* events are appended and the Dash surface is never replaced — the Dash log
|
|
521
|
+
* is the UI's scrollback projection; only OMP's own context shrinks.
|
|
522
|
+
*/
|
|
523
|
+
async #handleCompactCommand(invocation) {
|
|
524
|
+
if (this.#disposed)
|
|
525
|
+
return { kind: "error", text: "Session is disposed." };
|
|
526
|
+
if (!this.#rpc.spawned) {
|
|
527
|
+
return { kind: "success", text: "Session context is empty — nothing to compact yet." };
|
|
528
|
+
}
|
|
529
|
+
const instructions = invocation.rawInput.trim();
|
|
530
|
+
const before = await this.#rpc.contextUsage();
|
|
531
|
+
try {
|
|
532
|
+
await this.#rpc.compact(instructions === "" ? undefined : instructions);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
return { kind: "error", text: `Compaction failed: ${String(error)}` };
|
|
536
|
+
}
|
|
537
|
+
const after = await this.#rpc.contextUsage();
|
|
538
|
+
if (before?.tokens === undefined || after?.tokens === undefined) {
|
|
539
|
+
return { kind: "success", text: "Compaction complete." };
|
|
540
|
+
}
|
|
541
|
+
const saved = before.tokens - after.tokens;
|
|
542
|
+
return {
|
|
543
|
+
kind: "success",
|
|
544
|
+
text: `Compaction complete. Context tokens: ${before.tokens.toLocaleString("en-US")} → ${after.tokens.toLocaleString("en-US")} (saved ${saved.toLocaleString("en-US")}).`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Queue a message for OMP-side delivery. `followUp` dispatches immediately
|
|
549
|
+
* (state-safe whether or not OMP is streaming); `steer` dispatches now when
|
|
550
|
+
* already streaming, otherwise parks until agent_start.
|
|
551
|
+
*/
|
|
552
|
+
#queueRemote(message, transport) {
|
|
553
|
+
if (this.#disposed)
|
|
554
|
+
return;
|
|
555
|
+
clearTimeout(this.#idleExitTimer);
|
|
556
|
+
this.#idleExitTimer = undefined;
|
|
557
|
+
this.#remoteQueue.push({ message, sent: false, transport });
|
|
558
|
+
if (this.#streaming || transport === "followUp")
|
|
559
|
+
this.#flushRemote();
|
|
560
|
+
}
|
|
561
|
+
#flushRemote() {
|
|
562
|
+
void this.#syncModelSelection().finally(() => {
|
|
563
|
+
for (const entry of this.#remoteQueue) {
|
|
564
|
+
if (entry.sent)
|
|
565
|
+
continue;
|
|
566
|
+
entry.sent = true;
|
|
567
|
+
const text = userMessageText(entry.message);
|
|
568
|
+
const sent = entry.transport === "followUp" ? this.#rpc.followUp(text) : this.#rpc.steer(text);
|
|
569
|
+
trace(`-> ${entry.transport} "${text.slice(0, 40)}"`);
|
|
570
|
+
void sent.catch((error) => this.#fail(error));
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The selection a turn should run under, resolved exactly as the native
|
|
576
|
+
* loop resolves it (`buildRequest`): the base is the session's logged
|
|
577
|
+
* `request/header` (the replay synthesizes it from OMP's transcript, so a
|
|
578
|
+
* resumed session keeps ITS last model) falling back to the global default;
|
|
579
|
+
* the `system-prompt/assemble` → `agent/request` waterfall pair then applies
|
|
580
|
+
* the apiproxy's installed selection, so a switch made in the Web UI this
|
|
581
|
+
* process still wins. OMP itself already restores the transcript's model on
|
|
582
|
+
`--resume`, which is why {@link #syncModelSelection} only pushes when the
|
|
583
|
+
* resolved target differs from OMP's live state.
|
|
584
|
+
*/
|
|
585
|
+
async #effectiveModelSelection() {
|
|
586
|
+
const base = this.#baseModelSelection();
|
|
587
|
+
const systemPrompt = this.ctx.get("systemPrompt");
|
|
588
|
+
if (systemPrompt !== undefined) {
|
|
589
|
+
try {
|
|
590
|
+
await systemPrompt.assemble({ agent: this, scope: this });
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
trace(`model selection assemble failed: ${String(error)}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const resolved = await this.#dispatch.waterfall("agent/request", { turn: this.#dashTurn, step: this.#step, signal: new AbortController().signal }, () => Promise.resolve(base));
|
|
598
|
+
if (resolved.provider !== "" && resolved.model !== "") {
|
|
599
|
+
return { provider: resolved.provider, model: resolved.model };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
trace(`model selection waterfall failed: ${String(error)}`);
|
|
604
|
+
}
|
|
605
|
+
return base;
|
|
606
|
+
}
|
|
607
|
+
/** Tier-2/3 base: the session's logged header config, else the global default, else agent options. */
|
|
608
|
+
#baseModelSelection() {
|
|
609
|
+
const persisted = this.session.requestHeader()?.config;
|
|
610
|
+
if (persisted !== undefined && persisted.provider !== "" && persisted.model !== "") {
|
|
611
|
+
return { provider: persisted.provider, model: persisted.model };
|
|
612
|
+
}
|
|
613
|
+
const service = this.ctx.get("agentDefaultModel");
|
|
614
|
+
const selection = service?.currentSelection();
|
|
615
|
+
if (selection !== undefined && selection.provider !== "" && selection.model !== "")
|
|
616
|
+
return selection;
|
|
617
|
+
return { provider: this.options.provider ?? "", model: this.options.model ?? "" };
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Sync the effective model selection into OMP before a turn is dispatched.
|
|
621
|
+
* `set_model` fires only when the resolved target differs from OMP's live
|
|
622
|
+
* model, so a plain resume (OMP already restored the transcript's model, and
|
|
623
|
+
* the replayed header names the same one) performs no round-trip at all.
|
|
624
|
+
*/
|
|
625
|
+
async #syncModelSelection() {
|
|
626
|
+
const target = await this.#effectiveModelSelection();
|
|
627
|
+
const key = `${target.provider}/${target.model}`;
|
|
628
|
+
if (key === this.#lastSyncedModel)
|
|
629
|
+
return;
|
|
630
|
+
const state = await this.#rpc.getState().catch(() => null);
|
|
631
|
+
const ompModel = state?.model;
|
|
632
|
+
if (ompModel?.provider === target.provider && ompModel?.id === target.model) {
|
|
633
|
+
this.#lastSyncedModel = key;
|
|
634
|
+
this.#recordModel(target);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
await this.#rpc.setModel(target.provider, target.model).catch((error) => {
|
|
638
|
+
trace(`set_model ${key} failed: ${String(error)}`);
|
|
639
|
+
});
|
|
640
|
+
this.#lastSyncedModel = key;
|
|
641
|
+
this.#recordModel(target);
|
|
642
|
+
}
|
|
643
|
+
/** Event-driven index update: mirror the live model switch immediately (D5.3). */
|
|
644
|
+
#recordModel(target) {
|
|
645
|
+
const store = getBridgeStore();
|
|
646
|
+
if (store === undefined)
|
|
647
|
+
return;
|
|
648
|
+
store.updateModel(String(this.session.id), target.provider, target.model);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Reserve a turn WITHOUT appending anything: the prompt path reserves
|
|
652
|
+
* synchronously, then commits turn/start + user/message only after the lazy
|
|
653
|
+
* cold start (#startTurn). #openTurn keeps the eager append for paths that
|
|
654
|
+
* run against an already-live child (#bridgeRemoteUser echoes).
|
|
655
|
+
*/
|
|
656
|
+
#reserveTurn() {
|
|
657
|
+
if (this.#turnOpen)
|
|
658
|
+
return;
|
|
659
|
+
this.#dashTurn = ++this.#lastTurn;
|
|
660
|
+
this.#step = 0;
|
|
661
|
+
this.#turnOpen = true;
|
|
662
|
+
this.#localUserPending = true;
|
|
663
|
+
}
|
|
664
|
+
#openTurn(message, localUser = true) {
|
|
665
|
+
trace(`#openTurn turnOpen=${this.#turnOpen} -> turn=${this.#dashTurn + 1} localUser=${localUser}`);
|
|
666
|
+
if (this.#turnOpen)
|
|
667
|
+
return;
|
|
668
|
+
this.#reserveTurn();
|
|
669
|
+
this.#localUserPending = localUser;
|
|
670
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
671
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
672
|
+
}
|
|
673
|
+
#closeTurn(reason) {
|
|
674
|
+
if (!this.#turnOpen)
|
|
675
|
+
return;
|
|
676
|
+
this.#turnOpen = false;
|
|
677
|
+
this.#localUserPending = false;
|
|
678
|
+
this.session.append("turn/end", { turn: this.#dashTurn, reason });
|
|
679
|
+
void this.#mirrorOmpTitle();
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Mirror OMP's own session title into a `session/title` event. Dash does not
|
|
683
|
+
* generate titles for OMP sessions (the native title rows are disabled in the
|
|
684
|
+
* bundle patch); OMP owns the title and only reports it through the SDK. Empty
|
|
685
|
+
* or unchanged titles are ignored, so a session with no OMP title keeps its
|
|
686
|
+
* creation value.
|
|
687
|
+
*/
|
|
688
|
+
async #mirrorOmpTitle() {
|
|
689
|
+
if (!this.#rpc.spawned)
|
|
690
|
+
return;
|
|
691
|
+
try {
|
|
692
|
+
const state = await this.#rpc.getState();
|
|
693
|
+
this.#applyOmpTitle(state.title);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// fail-soft: a missing title never affects the turn
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Record a title OMP reported. OMP is a title *provider* (source.kind:
|
|
701
|
+
* "provider"), not Dash's built-in fallback. The event belongs to the
|
|
702
|
+
* `session-title` package's vocabulary, which the bridge does not import, so
|
|
703
|
+
* the append is cast; it is synchronous and never throws into the turn.
|
|
704
|
+
*/
|
|
705
|
+
#applyOmpTitle(title) {
|
|
706
|
+
if (typeof title !== "string" || title.trim() === "")
|
|
707
|
+
return;
|
|
708
|
+
if (title === this.#mirroredTitle)
|
|
709
|
+
return;
|
|
710
|
+
this.#mirroredTitle = title;
|
|
711
|
+
try {
|
|
712
|
+
this.session.append("session/title", {
|
|
713
|
+
title,
|
|
714
|
+
messageSeqs: [],
|
|
715
|
+
source: { kind: "provider", provider: "omp" },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// fail-soft: a title is never allowed to affect the turn
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Append the deferred `step/start` now that the step's first message has
|
|
724
|
+
* revealed which Dash turn owns it (a queued follow-up opens a fresh turn).
|
|
725
|
+
*/
|
|
726
|
+
#commitStepStart() {
|
|
727
|
+
if (!this.#stepStartPending)
|
|
728
|
+
return;
|
|
729
|
+
this.#stepStartPending = false;
|
|
730
|
+
if (!this.#turnOpen)
|
|
731
|
+
return;
|
|
732
|
+
this.#step += 1;
|
|
733
|
+
this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Bridge an OMP `message_start(role=user)` that was NOT appended locally.
|
|
737
|
+
* A `followUp` echo closes the current Dash turn and opens a fresh one with
|
|
738
|
+
* the retained message (OMP queued it and is now generating its turn); a
|
|
739
|
+
* steering/injection echo adds a `user/message` step to the current turn.
|
|
740
|
+
* Falls back to OMP's own content if no retained message matches.
|
|
741
|
+
*/
|
|
742
|
+
#bridgeRemoteUser(ompMessage) {
|
|
743
|
+
const entry = this.#remoteQueue.shift();
|
|
744
|
+
if (entry === undefined) {
|
|
745
|
+
this.#closeTurn({ kind: "completed" });
|
|
746
|
+
this.#openTurn(userMessageFromOmp(ompMessage), false);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (entry.transport === "followUp") {
|
|
750
|
+
this.#closeTurn({ kind: "completed" });
|
|
751
|
+
this.#openTurn(entry.message, false);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
this.session.append("user/message", entry.message, { surfaceOp: "append" });
|
|
755
|
+
}
|
|
756
|
+
#beginActivity() {
|
|
757
|
+
// Settle any prior in-flight activity first: `send()` can inject a prompt
|
|
758
|
+
// mid-stream (streamingBehavior followUp/steer), superseding the current
|
|
759
|
+
// activity promise. Without settling the old one, whenIdle()'s do-while
|
|
760
|
+
// would await an orphaned promise that never resolves.
|
|
761
|
+
this.#resolveActivityDone();
|
|
762
|
+
this.#activityDone = new Promise((resolve) => {
|
|
763
|
+
this.#resolveActivityDone = resolve;
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
#endActivity() {
|
|
767
|
+
this.#resolveActivityDone();
|
|
768
|
+
}
|
|
769
|
+
#markRunning() {
|
|
770
|
+
clearTimeout(this.#idleExitTimer);
|
|
771
|
+
this.#idleExitTimer = undefined;
|
|
772
|
+
if (this.#streaming)
|
|
773
|
+
return;
|
|
774
|
+
this.#streaming = true;
|
|
775
|
+
this.#dispatch.emit("agent/status", { status: "running" });
|
|
776
|
+
}
|
|
777
|
+
#markIdle() {
|
|
778
|
+
if (!this.#streaming)
|
|
779
|
+
return;
|
|
780
|
+
this.#streaming = false;
|
|
781
|
+
this.#dispatch.emit("agent/status", { status: "idle" });
|
|
782
|
+
this.#armIdleExit();
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Schedule the idle exit. Skipped while deliveries are still queued (an
|
|
786
|
+
* unanswered parked injection means work is pending, not idle) or when the
|
|
787
|
+
* exit is disabled. Any later activity restarts or cancels the timer.
|
|
788
|
+
*/
|
|
789
|
+
#armIdleExit() {
|
|
790
|
+
if (this.#disposed || this.#onIdleExit === undefined || OMP_IDLE_EXIT_MS === 0)
|
|
791
|
+
return;
|
|
792
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
793
|
+
return;
|
|
794
|
+
clearTimeout(this.#idleExitTimer);
|
|
795
|
+
this.#idleExitTimer = setTimeout(() => {
|
|
796
|
+
this.#idleExitTimer = undefined;
|
|
797
|
+
void this.#revalidateIdleExit();
|
|
798
|
+
}, OMP_IDLE_EXIT_MS);
|
|
799
|
+
this.#idleExitTimer.unref?.();
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Five-fold quiescence re-validation at fire time. The idle timer is only a
|
|
803
|
+
* hint: before teardown, re-confirm no live work via local signals and OMP's
|
|
804
|
+
* own self-attestation (get_state streaming/compacting/queued, get_subagents
|
|
805
|
+
* liveness). Any busy signal re-arms instead of killing; a query failure
|
|
806
|
+
* fail-softs to the local signals rather than forcing a teardown.
|
|
807
|
+
*/
|
|
808
|
+
async #revalidateIdleExit() {
|
|
809
|
+
if (this.#disposed || this.#streaming)
|
|
810
|
+
return this.#armIdleExit();
|
|
811
|
+
// Never-spawned draft: no child exists and no work is possible — drop the
|
|
812
|
+
// exit instead of re-arming timers or querying the rpc (passive queries
|
|
813
|
+
// must never conjure a child).
|
|
814
|
+
if (!this.#rpc.spawned)
|
|
815
|
+
return;
|
|
816
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
817
|
+
return this.#armIdleExit();
|
|
818
|
+
if (this.#approvalAborts.size > 0)
|
|
819
|
+
return this.#armIdleExit();
|
|
820
|
+
try {
|
|
821
|
+
const state = await this.#rpc.getState();
|
|
822
|
+
if (this.#streaming)
|
|
823
|
+
return this.#armIdleExit();
|
|
824
|
+
if (state.isStreaming)
|
|
825
|
+
return this.#armIdleExit();
|
|
826
|
+
if (state.isCompacting === true)
|
|
827
|
+
return this.#armIdleExit();
|
|
828
|
+
if (typeof state.queuedMessageCount === "number" && state.queuedMessageCount > 0)
|
|
829
|
+
return this.#armIdleExit();
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Self-attestation unavailable: local signals remain authoritative.
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
const subagents = await this.#rpc.getSubagents();
|
|
836
|
+
if (this.#streaming)
|
|
837
|
+
return this.#armIdleExit();
|
|
838
|
+
if (subagents.length > 0)
|
|
839
|
+
return this.#armIdleExit();
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// getSubagents fail-softs already; belt-and-suspenders.
|
|
843
|
+
}
|
|
844
|
+
if (this.#streaming)
|
|
845
|
+
return this.#armIdleExit();
|
|
846
|
+
trace(`idle exit after ${OMP_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
|
|
847
|
+
this.#onIdleExit?.();
|
|
848
|
+
}
|
|
849
|
+
#fail(error) {
|
|
850
|
+
this.#abandonStreamBridge();
|
|
851
|
+
if (this.#disposed)
|
|
852
|
+
return;
|
|
853
|
+
trace(`#fail ${String(error).slice(0, 200)}`);
|
|
854
|
+
this.#closeTurn({ kind: "error", error: { message: String(error), code: "UNKNOWN" } });
|
|
855
|
+
this.#markIdle();
|
|
856
|
+
this.#endActivity();
|
|
857
|
+
}
|
|
858
|
+
#handleEvent(event) {
|
|
859
|
+
switch (event.type) {
|
|
860
|
+
case "agent_start":
|
|
861
|
+
trace("event agent_start");
|
|
862
|
+
this.#markRunning();
|
|
863
|
+
this.#flushRemote();
|
|
864
|
+
break;
|
|
865
|
+
case "turn_start":
|
|
866
|
+
// Defer `step/start`: the step's owning turn is only known once its
|
|
867
|
+
// first message arrives (a queued follow-up opens a fresh turn).
|
|
868
|
+
trace("event turn_start");
|
|
869
|
+
this.#stepStartPending = true;
|
|
870
|
+
this.#pendingFailure = null;
|
|
871
|
+
break;
|
|
872
|
+
case "message_start": {
|
|
873
|
+
const message = event.message;
|
|
874
|
+
if (message?.role === "user") {
|
|
875
|
+
if (this.#localUserPending) {
|
|
876
|
+
// OMP is echoing the user message #openTurn already appended.
|
|
877
|
+
this.#localUserPending = false;
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
// A queued follow-up / steering message delivered by OMP.
|
|
881
|
+
this.#bridgeRemoteUser(message);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else if (message?.role === "assistant") {
|
|
885
|
+
// Reset per-message streaming accumulators.
|
|
886
|
+
this.#abandonStreamBridge();
|
|
887
|
+
}
|
|
888
|
+
// role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
|
|
889
|
+
this.#commitStepStart();
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
case "message_update": {
|
|
893
|
+
const delta = event.assistantMessageEvent;
|
|
894
|
+
if (delta !== undefined)
|
|
895
|
+
this.#handleUpdate(delta);
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
case "message_end": {
|
|
899
|
+
const message = event.message;
|
|
900
|
+
if (message === undefined)
|
|
901
|
+
break;
|
|
902
|
+
const role = message.role;
|
|
903
|
+
if (role === "assistant") {
|
|
904
|
+
const failure = ompFailure(message);
|
|
905
|
+
if (failure !== undefined) {
|
|
906
|
+
this.#pendingFailure = failure;
|
|
907
|
+
this.#abandonStreamBridge();
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
this.#appendAssistantMessage(message);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
else if (role === "toolResult") {
|
|
914
|
+
this.#appendToolResultMessage(message);
|
|
915
|
+
}
|
|
916
|
+
else if (role !== "user") {
|
|
917
|
+
process.stderr.write(`omp rpc: unhandled message_end role "${role}"\n`);
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case "tool_execution_start": {
|
|
922
|
+
const callId = String(event.toolCallId ?? "");
|
|
923
|
+
const name = String(event.toolName ?? "");
|
|
924
|
+
this.session.append("tool/call", {
|
|
925
|
+
turn: this.#dashTurn,
|
|
926
|
+
step: this.#step,
|
|
927
|
+
callId: ToolCallId(callId),
|
|
928
|
+
name,
|
|
929
|
+
arguments: JSON.stringify(event.args ?? {}),
|
|
930
|
+
});
|
|
931
|
+
// The assistant's pending tool call is now materialized as a running call.
|
|
932
|
+
this.#pendingToolCalls = this.#pendingToolCalls.filter((pending) => pending.callId !== callId);
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
case "extension_ui_request":
|
|
936
|
+
void this.#handleExtensionUiRequest(event);
|
|
937
|
+
break;
|
|
938
|
+
case "tool_execution_end":
|
|
939
|
+
this.#appendToolResult(event);
|
|
940
|
+
break;
|
|
941
|
+
case "turn_end":
|
|
942
|
+
trace("event turn_end");
|
|
943
|
+
if (this.#turnOpen) {
|
|
944
|
+
this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
|
|
945
|
+
}
|
|
946
|
+
break;
|
|
947
|
+
case "agent_end": {
|
|
948
|
+
trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
|
|
949
|
+
const pendingFailure = this.#pendingFailure;
|
|
950
|
+
this.#pendingFailure = null;
|
|
951
|
+
this.#closeTurn(this.#cancelCause !== null
|
|
952
|
+
? { kind: "aborted", reason: this.#cancelCause }
|
|
953
|
+
: pendingFailure !== null
|
|
954
|
+
? { kind: "error", error: pendingFailure }
|
|
955
|
+
: { kind: "completed" });
|
|
956
|
+
this.#cancelCause = null;
|
|
957
|
+
this.#markIdle();
|
|
958
|
+
this.#endActivity();
|
|
959
|
+
// Live metadata the Dash surface has no projection seam for yet:
|
|
960
|
+
// surface OMP's own accounting (tokens, contextUsage, cost) in the
|
|
961
|
+
// diagnostic trace so it is observable per completed turn.
|
|
962
|
+
void this.#rpc
|
|
963
|
+
.getSessionStats()
|
|
964
|
+
.then((stats) => {
|
|
965
|
+
trace(`sessionStats tokens=${JSON.stringify(stats.tokens ?? {})} contextUsage=${String(stats.contextUsage)} cost=${JSON.stringify(stats.cost ?? {})}`);
|
|
966
|
+
})
|
|
967
|
+
.catch(() => { });
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** The open stream bridge for this message, starting one on first use. */
|
|
973
|
+
#ensureStreamBridge() {
|
|
974
|
+
if (this.#streamBridge === undefined || this.#streamBridge.ended) {
|
|
975
|
+
this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
|
|
976
|
+
this.#streamBridge.start(this.#dashTurn, this.#step);
|
|
977
|
+
}
|
|
978
|
+
return this.#streamBridge;
|
|
979
|
+
}
|
|
980
|
+
/** Abandon any open stream bridge — failure paths never settle. */
|
|
981
|
+
#abandonStreamBridge() {
|
|
982
|
+
if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
|
|
983
|
+
this.#streamBridge.abandon();
|
|
984
|
+
this.#streamBridge = undefined;
|
|
985
|
+
}
|
|
986
|
+
#handleUpdate(delta) {
|
|
987
|
+
switch (delta.type) {
|
|
988
|
+
case "text_delta":
|
|
989
|
+
if (typeof delta.delta === "string" && delta.delta !== "") {
|
|
990
|
+
const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
|
|
991
|
+
// v2: live text no longer appends `assistant/chunk` log events — the
|
|
992
|
+
// stream publishes as dense agent frames and embeds durably in the
|
|
993
|
+
// final assistant/message.
|
|
994
|
+
this.#ensureStreamBridge().push(chunk);
|
|
995
|
+
}
|
|
996
|
+
break;
|
|
997
|
+
default:
|
|
998
|
+
// text_start/text_end/thinking_*/toolcall_* are folded into the final message.
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Bridge an OMP `extension_ui_request`. Approval cards (`select` with an
|
|
1004
|
+
* "Approve" option) round-trip through the Dash approval seam
|
|
1005
|
+
* (`ctx.get("approval").request()`), which the Web UI renders as an
|
|
1006
|
+
* ApprovalPanel. Every OTHER blocking dialog (confirm/input/editor, or a
|
|
1007
|
+
* select lacking "Approve") gets the universal cancellation so OMP never
|
|
1008
|
+
* blocks forever.
|
|
1009
|
+
*/
|
|
1010
|
+
async #handleExtensionUiRequest(event) {
|
|
1011
|
+
const id = String(event.id ?? "");
|
|
1012
|
+
if (id === "")
|
|
1013
|
+
return;
|
|
1014
|
+
const method = String(event.method ?? "");
|
|
1015
|
+
const options = Array.isArray(event.options) ? event.options : [];
|
|
1016
|
+
if (method !== "select" || !options.includes("Approve")) {
|
|
1017
|
+
// Not an approval card: cancel the dialog so OMP can keep going.
|
|
1018
|
+
this.#rpc.sendRaw({ type: "extension_ui_response", id, cancelled: true });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const title = typeof event.title === "string" ? event.title : "";
|
|
1022
|
+
const pending = this.#matchPendingToolCall(title);
|
|
1023
|
+
const toolName = pending !== null && pending.name !== "" ? pending.name : approvalToolName(title);
|
|
1024
|
+
const callId = pending !== null ? ToolCallId(pending.callId) : undefined;
|
|
1025
|
+
const reason = title !== "" ? title : undefined;
|
|
1026
|
+
// `ctx.approval` is provided by a sibling fiber in the real profile, so a
|
|
1027
|
+
// property read throws "cannot get property ... without inject"; `ctx.get`
|
|
1028
|
+
// resolves the store from any ctx instead.
|
|
1029
|
+
const approval = this.ctx.get("approval");
|
|
1030
|
+
if (approval === undefined) {
|
|
1031
|
+
// Fail closed: without an answerer the tool must not run.
|
|
1032
|
+
this.#rpc.sendRaw({ type: "extension_ui_response", id, value: "Deny" });
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const controller = new AbortController();
|
|
1036
|
+
this.#approvalAborts.add(controller);
|
|
1037
|
+
try {
|
|
1038
|
+
const outcome = await raceApproval(approval.request({
|
|
1039
|
+
agent: this,
|
|
1040
|
+
toolName,
|
|
1041
|
+
...(callId === undefined ? {} : { callId }),
|
|
1042
|
+
...(reason === undefined ? {} : { reason }),
|
|
1043
|
+
signal: controller.signal,
|
|
1044
|
+
}), controller, OMP_APPROVAL_TIMEOUT_MS);
|
|
1045
|
+
// Fail closed: only an explicit one-shot grant approves.
|
|
1046
|
+
this.#rpc.sendRaw({
|
|
1047
|
+
type: "extension_ui_response",
|
|
1048
|
+
id,
|
|
1049
|
+
value: outcome === "allowed-once" ? "Approve" : "Deny",
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
// A synchronous throw (no open turn / audit append failure) still denies
|
|
1054
|
+
// rather than hanging OMP.
|
|
1055
|
+
this.#rpc.sendRaw({ type: "extension_ui_response", id, value: "Deny" });
|
|
1056
|
+
}
|
|
1057
|
+
finally {
|
|
1058
|
+
this.#approvalAborts.delete(controller);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Match an approval card to its pending tool call, preferring the pending
|
|
1063
|
+
* call whose name equals the card title's tool name and falling back to the
|
|
1064
|
+
* first pending call. Consumes the match so a denied card (which never emits
|
|
1065
|
+
* `tool_execution_start`) can't be re-matched by a later card.
|
|
1066
|
+
*/
|
|
1067
|
+
#matchPendingToolCall(title) {
|
|
1068
|
+
if (this.#pendingToolCalls.length === 0)
|
|
1069
|
+
return null;
|
|
1070
|
+
const titleName = approvalToolName(title);
|
|
1071
|
+
let index = this.#pendingToolCalls.findIndex((pending) => pending.name !== "" && pending.name === titleName);
|
|
1072
|
+
if (index === -1)
|
|
1073
|
+
index = 0;
|
|
1074
|
+
const [matched] = this.#pendingToolCalls.splice(index, 1);
|
|
1075
|
+
return matched ?? null;
|
|
1076
|
+
}
|
|
1077
|
+
#appendAssistantMessage(message) {
|
|
1078
|
+
this.#pendingToolCalls = toolCalls(message.content);
|
|
1079
|
+
const content = convertContent(message.content);
|
|
1080
|
+
const usage = convertUsage(message.usage);
|
|
1081
|
+
const assistant = createAssistantMessage({
|
|
1082
|
+
content,
|
|
1083
|
+
source: {
|
|
1084
|
+
provider: String(message.provider ?? this.options.provider ?? ""),
|
|
1085
|
+
model: String(message.model ?? this.options.model ?? ""),
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
const bridge = this.#streamBridge;
|
|
1089
|
+
// v2: the attempt's timed chunks embed in the event itself;
|
|
1090
|
+
// `sourceEventSeqs` is forbidden on assistant/message in v2.
|
|
1091
|
+
const event = this.session.append("assistant/message", {
|
|
1092
|
+
turn: this.#dashTurn,
|
|
1093
|
+
step: this.#step,
|
|
1094
|
+
message: assistant,
|
|
1095
|
+
stream: bridge?.records ?? [],
|
|
1096
|
+
...(usage === undefined ? {} : { usage }),
|
|
1097
|
+
}, {
|
|
1098
|
+
surfaceOp: "append",
|
|
1099
|
+
});
|
|
1100
|
+
if (bridge !== undefined && !bridge.ended)
|
|
1101
|
+
bridge.settle(event.seq);
|
|
1102
|
+
this.#streamBridge = undefined;
|
|
1103
|
+
}
|
|
1104
|
+
#appendToolResult(event) {
|
|
1105
|
+
const callId = String(event.toolCallId ?? "");
|
|
1106
|
+
this.#bridgedToolResults.add(callId);
|
|
1107
|
+
const result = event.result;
|
|
1108
|
+
const isError = Boolean(event.isError ?? result?.isError ?? false);
|
|
1109
|
+
const message = createToolResultMessage({
|
|
1110
|
+
callId: ToolCallId(callId),
|
|
1111
|
+
content: convertContent(result?.content),
|
|
1112
|
+
isError,
|
|
1113
|
+
});
|
|
1114
|
+
this.session.append("tool/result", {
|
|
1115
|
+
turn: this.#dashTurn,
|
|
1116
|
+
step: this.#step,
|
|
1117
|
+
message,
|
|
1118
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
1119
|
+
}, {
|
|
1120
|
+
surfaceOp: "append",
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
#appendToolResultMessage(message) {
|
|
1124
|
+
const callId = String(message.toolCallId ?? "");
|
|
1125
|
+
// The normal path bridges via `tool_execution_end` first; skip the
|
|
1126
|
+
// `message_end(toolResult)` duplicate OMP always also emits.
|
|
1127
|
+
if (this.#bridgedToolResults.has(callId))
|
|
1128
|
+
return;
|
|
1129
|
+
this.#bridgedToolResults.add(callId);
|
|
1130
|
+
const isError = Boolean(message.isError ?? false);
|
|
1131
|
+
const result = createToolResultMessage({
|
|
1132
|
+
callId: ToolCallId(callId),
|
|
1133
|
+
content: convertContent(message.content),
|
|
1134
|
+
isError,
|
|
1135
|
+
});
|
|
1136
|
+
this.session.append("tool/result", {
|
|
1137
|
+
turn: this.#dashTurn,
|
|
1138
|
+
step: this.#step,
|
|
1139
|
+
message: result,
|
|
1140
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
1141
|
+
}, {
|
|
1142
|
+
surfaceOp: "append",
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|