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,1056 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* v2 live assistant-stream publication for the Codex bridge — the same frame
|
|
7
|
+
* protocol the reference loop's AssistantStreamAttempt speaks (start marker,
|
|
8
|
+
* dense zero-based chunks, terminal settlement) emitted over this agent's own
|
|
9
|
+
* dispatch, so the session-controller folds Codex's stream into its reconnect
|
|
10
|
+
* baseline. Chunks also accumulate as raw durable records embedded in the
|
|
11
|
+
* final assistant/message.
|
|
12
|
+
*/
|
|
13
|
+
class AssistantStreamBridge {
|
|
14
|
+
attemptId;
|
|
15
|
+
records = [];
|
|
16
|
+
#nextRevision;
|
|
17
|
+
#emit;
|
|
18
|
+
#index = 0;
|
|
19
|
+
#terminal = false;
|
|
20
|
+
constructor(sessionId, attempt, nextRevision, emit) {
|
|
21
|
+
this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`);
|
|
22
|
+
this.#nextRevision = nextRevision;
|
|
23
|
+
this.#emit = emit;
|
|
24
|
+
}
|
|
25
|
+
/** Whether the terminal frame already fired. */
|
|
26
|
+
get ended() {
|
|
27
|
+
return this.#terminal;
|
|
28
|
+
}
|
|
29
|
+
/** Opening marker before the first delivered chunk. */
|
|
30
|
+
start(turn, step) {
|
|
31
|
+
this.#emit({ type: "start", attemptId: this.attemptId, revision: this.#nextRevision(), turn, step });
|
|
32
|
+
}
|
|
33
|
+
/** One live chunk: durable record plus dense process-local frame. */
|
|
34
|
+
push(chunk) {
|
|
35
|
+
const time = Date.now();
|
|
36
|
+
this.records.push({ type: "chunk", time, chunk });
|
|
37
|
+
this.#emit({ type: "chunk", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index++, time, chunk });
|
|
38
|
+
}
|
|
39
|
+
/** Terminal settlement after the durable assistant/message committed. */
|
|
40
|
+
settle(seq) {
|
|
41
|
+
this.#terminal = true;
|
|
42
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "committed", eventType: "assistant/message", seq } });
|
|
43
|
+
}
|
|
44
|
+
/** No durable attempt event will commit. */
|
|
45
|
+
abandon() {
|
|
46
|
+
this.#terminal = true;
|
|
47
|
+
this.#emit({ type: "end", attemptId: this.attemptId, revision: this.#nextRevision(), index: this.#index, outcome: { kind: "abandoned" } });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Diagnostic trace (set CODEX_TRACE=1 on the dsh process to enable). */
|
|
51
|
+
const TRACE = process.env.CODEX_TRACE === "1";
|
|
52
|
+
const trace = (...parts) => {
|
|
53
|
+
if (TRACE)
|
|
54
|
+
process.stderr.write(`[codex-agent ${Date.now() % 1_000_000}] ${parts.join(" ")}\n`);
|
|
55
|
+
};
|
|
56
|
+
export function convertContent(blocks) {
|
|
57
|
+
if (blocks === undefined)
|
|
58
|
+
return [];
|
|
59
|
+
const result = [];
|
|
60
|
+
for (const block of blocks) {
|
|
61
|
+
switch (block.type) {
|
|
62
|
+
case "text":
|
|
63
|
+
if (typeof block.text === "string")
|
|
64
|
+
result.push({ type: "text", text: block.text });
|
|
65
|
+
break;
|
|
66
|
+
case "thinking":
|
|
67
|
+
if (typeof block.thinking === "string")
|
|
68
|
+
result.push({ type: "reasoning", text: block.thinking });
|
|
69
|
+
break;
|
|
70
|
+
case "toolCall": {
|
|
71
|
+
const id = typeof block.id === "string" ? block.id : "";
|
|
72
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
73
|
+
const args = typeof block.arguments === "string" ? block.arguments : JSON.stringify(block.arguments ?? {});
|
|
74
|
+
result.push({ type: "tool-call", id: ToolCallId(id), name, arguments: args });
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
// images / unknown blocks are not bridged in V1.
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Classify the wire's `stopReason: "error"` assistant message into a Dash
|
|
86
|
+
* `failure` (`{ message, code }`). This is the same taxonomy the harness
|
|
87
|
+
* adapters use for `turn/end` reasons — `AUTH`, `RATE_LIMIT`, `QUOTA`,
|
|
88
|
+
* `SERVER` — so the Web UI renders it through its own error path instead of
|
|
89
|
+
* an empty assistant bubble. Quota/usage wording is checked before the
|
|
90
|
+
* generic 403→AUTH because some providers report an exhausted billing quota
|
|
91
|
+
* as HTTP 403.
|
|
92
|
+
*/
|
|
93
|
+
export function wireFailure(message) {
|
|
94
|
+
if (message.stopReason !== "error")
|
|
95
|
+
return undefined;
|
|
96
|
+
const status = typeof message.errorStatus === "number" ? message.errorStatus : undefined;
|
|
97
|
+
const text = typeof message.errorMessage === "string" && message.errorMessage.trim() !== ""
|
|
98
|
+
? message.errorMessage.trim()
|
|
99
|
+
: undefined;
|
|
100
|
+
const detail = text ?? "";
|
|
101
|
+
let code;
|
|
102
|
+
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)) {
|
|
103
|
+
code = QUOTA_EXCEEDED_CODE;
|
|
104
|
+
}
|
|
105
|
+
else if (status === 401 || status === 403) {
|
|
106
|
+
code = "AUTH";
|
|
107
|
+
}
|
|
108
|
+
else if (status === 429) {
|
|
109
|
+
code = "RATE_LIMIT";
|
|
110
|
+
}
|
|
111
|
+
else if (status !== undefined && status >= 500) {
|
|
112
|
+
code = "SERVER";
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
code = "UNKNOWN";
|
|
116
|
+
}
|
|
117
|
+
return { message: text ?? (status !== undefined ? `HTTP ${status}` : "model request failed"), code };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Classify a NATIVE failure (the codex process / SDK stream itself, surfaced
|
|
121
|
+
* through `onFailure`) into the stable turn-error codes the resume surface
|
|
122
|
+
* uses (plan RC-4): a missing or unreadable rollout is `ROLLOUT_MISSING`; any
|
|
123
|
+
* other refusal of the native resume/re-attach is `NATIVE_REJECTED`.
|
|
124
|
+
* Model-side failures that ride the wire as `stopReason: "error"` are
|
|
125
|
+
* classified by {@link wireFailure} instead. Unrecognized shapes stay
|
|
126
|
+
* `UNKNOWN` — only the two stable codes mark the session not resumable.
|
|
127
|
+
*/
|
|
128
|
+
export function classifyNativeFailure(error) {
|
|
129
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
130
|
+
if (/rollout|no such (session|thread)|(session|thread)[\w -]{0,24}not[\w -]{0,8}found|unable to (find|locate|load|read|resume)|failed to (load|read|resume)/i.test(text)) {
|
|
131
|
+
return { message: text, code: "ROLLOUT_MISSING" };
|
|
132
|
+
}
|
|
133
|
+
if (/resume|re-?attach|corrupt|invalid[\w -]{0,24}(session|thread)/i.test(text)) {
|
|
134
|
+
return { message: text, code: "NATIVE_REJECTED" };
|
|
135
|
+
}
|
|
136
|
+
return { message: text, code: "UNKNOWN" };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Map Codex's turn usage accounting into Dash `TokenUsage`. Codex reports
|
|
140
|
+
* five disjoint snake_case counters per turn (`turn_end.data.usage`) and its
|
|
141
|
+
* `reasoning_output_tokens` is ADDITIVE to `output_tokens` (the captured
|
|
142
|
+
* fixture shows output 3, reasoning 18), while the DSH turn fold requires
|
|
143
|
+
* reasoning ⊆ output plus an exact `totalTokens` when both cache buckets are
|
|
144
|
+
* present (upstream token-meter `normalizeUsage`). The total is therefore
|
|
145
|
+
* synthesized from Codex's own counters:
|
|
146
|
+
*
|
|
147
|
+
* totalTokens = input + cachedInput + cacheWrite + output + reasoning
|
|
148
|
+
*
|
|
149
|
+
* Emission rules (plan ruling 6 — omp/pi shape, no invented tokens):
|
|
150
|
+
* - a cache bucket rides through only when > 0 (a zero bucket carries no
|
|
151
|
+
* information but would trip the fold's both-buckets exact-prompt
|
|
152
|
+
* equality for nothing);
|
|
153
|
+
* - `reasoningTokens` rides through only when it fits the fold's subset
|
|
154
|
+
* rule (`reasoning <= output`); codex's additive reasoning is otherwise
|
|
155
|
+
* omitted from the breakdown while still counted in the synthesized
|
|
156
|
+
* total (fail-closed fold philosophy: no attempt is inferred from a
|
|
157
|
+
* contradictory sample).
|
|
158
|
+
*/
|
|
159
|
+
export function convertUsage(usage) {
|
|
160
|
+
if (usage === null || typeof usage !== "object")
|
|
161
|
+
return undefined;
|
|
162
|
+
const u = usage;
|
|
163
|
+
const count = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
164
|
+
const inputTokens = count(u.input_tokens) ?? 0;
|
|
165
|
+
const outputTokens = count(u.output_tokens) ?? 0;
|
|
166
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
167
|
+
return undefined;
|
|
168
|
+
const cachedInput = count(u.cached_input_tokens);
|
|
169
|
+
const cacheWrite = count(u.cache_write_input_tokens);
|
|
170
|
+
const reasoning = count(u.reasoning_output_tokens);
|
|
171
|
+
const cacheReadTokens = cachedInput !== undefined && cachedInput > 0 ? cachedInput : undefined;
|
|
172
|
+
const cacheWriteTokens = cacheWrite !== undefined && cacheWrite > 0 ? cacheWrite : undefined;
|
|
173
|
+
const reasoningTokens = reasoning !== undefined && reasoning > 0 && reasoning <= outputTokens ? reasoning : undefined;
|
|
174
|
+
return {
|
|
175
|
+
inputTokens,
|
|
176
|
+
outputTokens,
|
|
177
|
+
totalTokens: inputTokens + (cacheReadTokens ?? 0) + (cacheWriteTokens ?? 0) + outputTokens + (reasoning ?? 0),
|
|
178
|
+
...(cacheReadTokens === undefined ? {} : { cacheReadTokens }),
|
|
179
|
+
...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }),
|
|
180
|
+
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Join the visible text blocks of a Dash user message into the Codex prompt string. */
|
|
184
|
+
function userMessageText(message) {
|
|
185
|
+
const parts = [];
|
|
186
|
+
for (const block of message.content) {
|
|
187
|
+
if (block.type === "text")
|
|
188
|
+
parts.push(block.text);
|
|
189
|
+
}
|
|
190
|
+
return parts.join("\n");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Dash-host plugin narration from the approval service: the policy-change
|
|
194
|
+
* notice `agent.inject` carries when `/permission` switches a live session's
|
|
195
|
+
* approval policy. Codex has no runtime approval control either (the policy
|
|
196
|
+
* is pinned at launch via the preset mapping; no SDK surface changes it
|
|
197
|
+
* mid-session), so forwarding the text would steer it into an in-flight turn
|
|
198
|
+
* (interrupting tool work) and echo it into history as a fake user prompt.
|
|
199
|
+
* Detect and drop it.
|
|
200
|
+
*/
|
|
201
|
+
function isApprovalNarration(message) {
|
|
202
|
+
const source = message.source;
|
|
203
|
+
return source.kind === "plugin" && source.plugin === "user-approval";
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Reconstruct a Dash user message from a wire `message_start(role=user)`
|
|
207
|
+
* payload. Used only as a defensive fallback when a queued delivery's
|
|
208
|
+
* retained message is missing (the client echoes text, so the visible
|
|
209
|
+
* content is preserved).
|
|
210
|
+
*/
|
|
211
|
+
function userMessageFromWire(message) {
|
|
212
|
+
return createUserMessage({
|
|
213
|
+
content: convertContent(message.content).filter((block) => block.type === "text"),
|
|
214
|
+
source: { kind: "user" },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
218
|
+
/**
|
|
219
|
+
* How long an agent may sit idle before its codex client is torn down. The
|
|
220
|
+
* Dash host keeps resumed agents registered forever (nothing else disposes
|
|
221
|
+
* them), and every idle agent pins a live thread. Tearing the agent down at
|
|
222
|
+
* idle costs nothing observable — the next prompt cold-resumes the session
|
|
223
|
+
* from Codex's rollout through the replay persistence. `0` disables the exit.
|
|
224
|
+
*/
|
|
225
|
+
const CODEX_IDLE_EXIT_MS = parseIdleExit(process.env.CODEX_IDLE_EXIT_MS);
|
|
226
|
+
function parseIdleExit(raw) {
|
|
227
|
+
if (raw === undefined || raw.trim() === "")
|
|
228
|
+
return 600_000;
|
|
229
|
+
const value = Number(raw);
|
|
230
|
+
return Number.isFinite(value) && value >= 0 ? value : 600_000;
|
|
231
|
+
}
|
|
232
|
+
export class CodexAgent {
|
|
233
|
+
id;
|
|
234
|
+
options;
|
|
235
|
+
session;
|
|
236
|
+
inbox;
|
|
237
|
+
ctx;
|
|
238
|
+
#client;
|
|
239
|
+
#loopCtx;
|
|
240
|
+
#scope;
|
|
241
|
+
#dispatch;
|
|
242
|
+
#streaming = false;
|
|
243
|
+
#activityDone = Promise.resolve();
|
|
244
|
+
#resolveActivityDone = () => { };
|
|
245
|
+
#dashTurn = 0;
|
|
246
|
+
#step = 0;
|
|
247
|
+
#lastTurn = 0;
|
|
248
|
+
#turnOpen = false;
|
|
249
|
+
#cancelCause = null;
|
|
250
|
+
/** Terminal model failure of the open turn (`message_end` stopReason "error"); consumed by `agent_end`. */
|
|
251
|
+
#pendingFailure = null;
|
|
252
|
+
/** Live v2 assistant-stream publication for the in-flight wire message. */
|
|
253
|
+
#streamBridge;
|
|
254
|
+
#assistantAttemptCounter = 0;
|
|
255
|
+
#assistantStreamRevision = 0;
|
|
256
|
+
/** The current turn's user/message was appended locally and the echo is still awaited. */
|
|
257
|
+
#localUserPending = false;
|
|
258
|
+
/** A `turn_start` arrived; `step/start` is deferred until the step's owning turn is known. */
|
|
259
|
+
#stepStartPending = false;
|
|
260
|
+
/**
|
|
261
|
+
* Deliveries forwarded through the client's own queue, in submission order.
|
|
262
|
+
* The client echoes each as `message_start(role=user)`; the bridge maps it
|
|
263
|
+
* by transport: a `followUp` echo closes the current Dash turn and opens a
|
|
264
|
+
* fresh one (the client queues it and auto-generates the next turn), a
|
|
265
|
+
* `steer` echo appends a `user/message` step to the current turn. `sent`
|
|
266
|
+
* marks entries already dispatched to the client (a parked injection
|
|
267
|
+
* flushes at agent_start).
|
|
268
|
+
*/
|
|
269
|
+
#remoteQueue = [];
|
|
270
|
+
#bridgedToolResults = new Set();
|
|
271
|
+
/**
|
|
272
|
+
* The open turn's FINAL assistant message, buffered from `message_end`
|
|
273
|
+
* until `turn_end` so the SAME turn's usage can land on it (the DSH
|
|
274
|
+
* turn-token fold refuses cross-turn samples). Flushed usage-less on
|
|
275
|
+
* failure/abort paths so the turn's visible text is never dropped.
|
|
276
|
+
*/
|
|
277
|
+
#deferredAssistant = null;
|
|
278
|
+
/** The last selection already synced to the client via `set_model`, as `provider/model`. */
|
|
279
|
+
#lastSyncedModel = null;
|
|
280
|
+
/** Fires when the agent has been idle past CODEX_IDLE_EXIT_MS (tears down the client). */
|
|
281
|
+
#onIdleExit;
|
|
282
|
+
#idleExitTimer = undefined;
|
|
283
|
+
#disposed = false;
|
|
284
|
+
/** One-shot guard for the first-turn session-identity commit (#bootstrapSessionIdentity). */
|
|
285
|
+
#sessionIdentityCommitted = false;
|
|
286
|
+
/** Adapter-owned runtime facts (system prompt / route metadata), when supplied. */
|
|
287
|
+
#runtimeInfo;
|
|
288
|
+
/** One-shot guard for the `system/message` surface node (never re-emitted). */
|
|
289
|
+
#systemMessageDone = false;
|
|
290
|
+
/** Last `request/context` route key (`provider/model`) — dedupes per-turn emits. */
|
|
291
|
+
#lastRouteKey = null;
|
|
292
|
+
constructor(loopCtx, id, options, session, client, onIdleExit, runtimeInfo) {
|
|
293
|
+
this.#loopCtx = loopCtx;
|
|
294
|
+
this.id = id;
|
|
295
|
+
this.options = options;
|
|
296
|
+
this.session = session;
|
|
297
|
+
this.#client = client;
|
|
298
|
+
this.#onIdleExit = onIdleExit;
|
|
299
|
+
this.#runtimeInfo = runtimeInfo;
|
|
300
|
+
this.#dispatch = agentEvents(loopCtx, this);
|
|
301
|
+
this.inbox = new Inbox();
|
|
302
|
+
this.#scope = createScope(loopCtx, this);
|
|
303
|
+
this.ctx = this.#scope.ctx.extend({ agent: this });
|
|
304
|
+
this.#lastTurn = session.snapshotEvents().findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
|
|
305
|
+
client.on((event) => this.#handleEvent(event));
|
|
306
|
+
client.onFailure((error) => this.#fail(error));
|
|
307
|
+
// Session-identity events (the agent-preset stamp) are committed on the
|
|
308
|
+
// FIRST turn by #bootstrapSessionIdentity — only once the client actually
|
|
309
|
+
// exists — so a freshly created session announces completely blank,
|
|
310
|
+
// exactly like the native agent-loop factory's: the backend takes zero
|
|
311
|
+
// part in the UI's browser-local new-session draft.
|
|
312
|
+
// `/permission` cannot work here: Codex pins the approval policy at
|
|
313
|
+
// launch and no SDK surface changes it mid-session. Registering the SAME
|
|
314
|
+
// name on this agent's own scope (a command-injected child of `agent.ctx`)
|
|
315
|
+
// SHADOWS the global permission command for this agent only — the switch
|
|
316
|
+
// then fails cleanly instead of appending approval/policy events Codex
|
|
317
|
+
// cannot honor. (The `inject` narration drop below stays as the backstop.)
|
|
318
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
319
|
+
name: "permission",
|
|
320
|
+
description: "Unavailable for Codex sessions (approval policy is fixed at Codex launch)",
|
|
321
|
+
input: { hint: "<preset>" },
|
|
322
|
+
handler: () => ({
|
|
323
|
+
kind: "error",
|
|
324
|
+
text: "Approval policy is fixed at Codex launch; runtime switching is not supported for Codex sessions.",
|
|
325
|
+
}),
|
|
326
|
+
}));
|
|
327
|
+
// `/compact` parity: the SDK line exposes no compaction surface (V1 gap
|
|
328
|
+
// table: no messages surgery/compact — fail-soft default). Shadowing the
|
|
329
|
+
// loop-owned global name keeps the (disabled) native pipeline from
|
|
330
|
+
// receiving it; the command node renders the clean refusal natively.
|
|
331
|
+
this.ctx.inject(["commands"], (cmdCtx) => cmdCtx.commands.register({
|
|
332
|
+
name: "compact",
|
|
333
|
+
description: "Unavailable for Codex sessions (the SDK line has no compaction surface)",
|
|
334
|
+
input: { hint: "[instructions]" },
|
|
335
|
+
handler: () => ({
|
|
336
|
+
kind: "error",
|
|
337
|
+
text: "Compaction is not available for Codex sessions: the SDK line has no compaction surface.",
|
|
338
|
+
}),
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
get status() {
|
|
342
|
+
return this.#streaming ? "running" : "idle";
|
|
343
|
+
}
|
|
344
|
+
send(message, target, wakeup) {
|
|
345
|
+
this.#deliver(message, target, wakeup);
|
|
346
|
+
}
|
|
347
|
+
followup(message) {
|
|
348
|
+
// The Web UI's `session.prompt` (mode "queue") routes through followup.
|
|
349
|
+
// Idle → `prompt` (opens the Dash turn locally); busy → the client's own
|
|
350
|
+
// queue (delivered as the next turn's user message and auto-generated).
|
|
351
|
+
// Mirrors dsh-agent-loop's `followup = send(input, "next-turn", true)`.
|
|
352
|
+
this.#deliver(message, "next-turn", true);
|
|
353
|
+
}
|
|
354
|
+
steer(message) {
|
|
355
|
+
// Mirrors dsh-agent-loop's `steer = send(input, "next-step", true)`: an
|
|
356
|
+
// idle driver starts a turn; a running driver consumes it at the next
|
|
357
|
+
// step (on the SDK line: queued followUp after the in-flight turn).
|
|
358
|
+
this.#deliver(message, "next-step", true);
|
|
359
|
+
}
|
|
360
|
+
inject(message) {
|
|
361
|
+
// Dash-host approval-policy narrations have no Codex runtime equivalent
|
|
362
|
+
// (the policy is pinned at launch; no SDK surface changes it mid-session).
|
|
363
|
+
// Forwarding the text would steer it into an in-flight turn — interrupting
|
|
364
|
+
// tool work — and echo it into history as a fake user prompt. Drop it; the
|
|
365
|
+
// accompanying `approval/policy` session event stays recorded so the UI's
|
|
366
|
+
// permission state remains truthful.
|
|
367
|
+
if (isApprovalNarration(message)) {
|
|
368
|
+
trace("inject: dropped approval-policy narration (Codex lacks runtime approval control)");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Mirrors dsh-agent-loop's `inject = send(input, "next-step", false)`:
|
|
372
|
+
// queued without waking; idle drivers leave it pending until a later
|
|
373
|
+
// follow-up/steer wakes them (flushed at the next agent_start).
|
|
374
|
+
this.#deliver(message, "next-step", false);
|
|
375
|
+
}
|
|
376
|
+
cancel(cause, _options) {
|
|
377
|
+
// No active run → no-op: arming `#cancelCause` here would misreport a later,
|
|
378
|
+
// unrelated completed turn as aborted. First cause wins: later cancels must
|
|
379
|
+
// not overwrite an earlier cause (dash contract).
|
|
380
|
+
if (!this.#streaming)
|
|
381
|
+
return;
|
|
382
|
+
if (this.#cancelCause === null)
|
|
383
|
+
this.#cancelCause = cause;
|
|
384
|
+
void this.#client.abort().catch(() => { });
|
|
385
|
+
}
|
|
386
|
+
async whenIdle() {
|
|
387
|
+
// Event-driven: wait for the current activity to settle (agent_end), then
|
|
388
|
+
// confirm the client reports quiescence via get_state (the mapping's poll).
|
|
389
|
+
let activity;
|
|
390
|
+
do {
|
|
391
|
+
activity = this.#activityDone;
|
|
392
|
+
await activity;
|
|
393
|
+
} while (activity !== this.#activityDone);
|
|
394
|
+
for (;;) {
|
|
395
|
+
const state = await this.#client.getState().catch(() => null);
|
|
396
|
+
if (state === null || !state.isStreaming)
|
|
397
|
+
return;
|
|
398
|
+
await delay(50);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
runMaintenance(task) {
|
|
402
|
+
// Codex has no maintenance concept; run the task directly.
|
|
403
|
+
return task(new AbortController().signal);
|
|
404
|
+
}
|
|
405
|
+
/** Stop the codex client and unwind the scoped world. Idempotent. */
|
|
406
|
+
async dispose() {
|
|
407
|
+
if (this.#disposed)
|
|
408
|
+
return;
|
|
409
|
+
this.#disposed = true;
|
|
410
|
+
clearTimeout(this.#idleExitTimer);
|
|
411
|
+
this.#idleExitTimer = undefined;
|
|
412
|
+
this.#client.close();
|
|
413
|
+
await this.#scope.dispose();
|
|
414
|
+
}
|
|
415
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
416
|
+
/**
|
|
417
|
+
* Route one message by the reference loop's (target, wakeup) semantics:
|
|
418
|
+
* an idle waking delivery starts a fresh local turn (`prompt`); a follow-up
|
|
419
|
+
* sent while a turn is open is forwarded to the client's own queue
|
|
420
|
+
* (`followUp`: the client delivers it as the next turn's user message and
|
|
421
|
+
* auto-generates that turn); steering/injection goes through `steer`
|
|
422
|
+
* (consumed at the next step, or parked until agent_start when not yet
|
|
423
|
+
* streaming).
|
|
424
|
+
*
|
|
425
|
+
* Busy is the LOCAL turn state (`#turnOpen`), not the `#streaming` flag:
|
|
426
|
+
* the client needs a moment to emit `agent_start` after a prompt, and in
|
|
427
|
+
* that gap a bare `prompt` is still safe on the SDK line, but keeping the
|
|
428
|
+
* omp queuing semantics makes the echo mapping identical.
|
|
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
|
+
// client 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: confirm the client is started,
|
|
461
|
+
* commit the session-identity events ahead of the turn (the agent-preset
|
|
462
|
+
* stamp), sync the model, then append turn/start + user/message and
|
|
463
|
+
* dispatch. A failed cold start synthesizes the failed turn (turn/end with
|
|
464
|
+
* the error reason) so the UI's error path renders it, then unwinds the
|
|
465
|
+
* reservation.
|
|
466
|
+
*/
|
|
467
|
+
async #startTurn(message) {
|
|
468
|
+
try {
|
|
469
|
+
await this.#client.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.#emitRequestContext();
|
|
487
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
488
|
+
void this.#client.prompt(userMessageText(message)).catch((error) => this.#fail(error));
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Stamp the Codex system prompt once per session as the step's surface node.
|
|
492
|
+
* `system/message` is a step-scoped surface event, so this runs only after
|
|
493
|
+
* `step/start` committed (inside the first open step of the first turn).
|
|
494
|
+
* Skips when the log already carries one (resume), when the adapter has no
|
|
495
|
+
* runtime bridge, and while the rollout HEAD is not yet readable — the call
|
|
496
|
+
* is retried on later steps until the text is available.
|
|
497
|
+
*/
|
|
498
|
+
#emitSystemMessage() {
|
|
499
|
+
if (this.#systemMessageDone)
|
|
500
|
+
return;
|
|
501
|
+
if (this.#runtimeInfo === undefined || !this.#turnOpen)
|
|
502
|
+
return;
|
|
503
|
+
this.#commitStepStart();
|
|
504
|
+
if (this.#step === 0)
|
|
505
|
+
return;
|
|
506
|
+
if (this.session.snapshotEvents().some((event) => event.type === "system/message")) {
|
|
507
|
+
this.#systemMessageDone = true;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const text = this.#runtimeInfo.systemPrompt();
|
|
511
|
+
if (text === undefined || text.trim() === "")
|
|
512
|
+
return;
|
|
513
|
+
this.session.append("system/message", {
|
|
514
|
+
turn: this.#dashTurn,
|
|
515
|
+
step: this.#step,
|
|
516
|
+
message: createSystemMessage(text, "aw.agent-adapter-codex"),
|
|
517
|
+
}, { surfaceOp: "append" });
|
|
518
|
+
this.#systemMessageDone = true;
|
|
519
|
+
trace(`system/message stamped (${text.length} chars)`);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Stamp route metadata for the next request (`request/context`): the
|
|
523
|
+
* effective provider/model plus the catalog's context window when known.
|
|
524
|
+
* Turn-enclosed (no step required) and deduped by route key, so it lands at
|
|
525
|
+
* the start of a turn and again only when the effective route changes.
|
|
526
|
+
*/
|
|
527
|
+
#emitRequestContext() {
|
|
528
|
+
if (this.#runtimeInfo === undefined || !this.#turnOpen)
|
|
529
|
+
return;
|
|
530
|
+
const route = this.#runtimeInfo.routeContext(this.options.model);
|
|
531
|
+
if (route === undefined)
|
|
532
|
+
return;
|
|
533
|
+
const key = `${route.provider}/${route.model}`;
|
|
534
|
+
if (key === this.#lastRouteKey)
|
|
535
|
+
return;
|
|
536
|
+
this.#lastRouteKey = key;
|
|
537
|
+
this.session.append("request/context", {
|
|
538
|
+
provider: route.provider,
|
|
539
|
+
model: route.model,
|
|
540
|
+
...(route.contextWindow === undefined ? {} : { contextWindow: route.contextWindow }),
|
|
541
|
+
});
|
|
542
|
+
trace(`request/context ${key}${route.contextWindow === undefined ? "" : ` ctx=${route.contextWindow}`}`);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* One-shot first-turn identity commit: the agent-preset stamp (idempotent).
|
|
546
|
+
* The SDK line exposes no system-prompt surface, so — unlike the omp
|
|
547
|
+
* bridge — no `system/message` node is emitted here; a Codex session's
|
|
548
|
+
* system prompt is Codex's own and never crosses the wire. The guard —
|
|
549
|
+
* skip when the seed already carries a `system/message` — stays for shape
|
|
550
|
+
* parity with resumed logs that might carry one.
|
|
551
|
+
*/
|
|
552
|
+
async #bootstrapSessionIdentity() {
|
|
553
|
+
if (this.#sessionIdentityCommitted)
|
|
554
|
+
return;
|
|
555
|
+
this.#sessionIdentityCommitted = true;
|
|
556
|
+
if (!this.session.snapshotEvents().some((event) => event.type === "agent-preset/selected" && event.data?.agentPreset === "codex")) {
|
|
557
|
+
this.session.append("agent-preset/selected", { agentPreset: "codex" });
|
|
558
|
+
}
|
|
559
|
+
// The system prompt is stamped separately by #emitSystemMessage once the
|
|
560
|
+
// thread's rollout HEAD is readable (Codex's own prompt is recovered from
|
|
561
|
+
// `session_meta.base_instructions`, never from a wire message).
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Queue a message for client-side delivery. `followUp` dispatches
|
|
565
|
+
* immediately (state-safe whether or not the client is streaming); `steer`
|
|
566
|
+
* dispatches now when already streaming, otherwise parks until
|
|
567
|
+
* agent_start.
|
|
568
|
+
*/
|
|
569
|
+
#queueRemote(message, transport) {
|
|
570
|
+
if (this.#disposed)
|
|
571
|
+
return;
|
|
572
|
+
clearTimeout(this.#idleExitTimer);
|
|
573
|
+
this.#idleExitTimer = undefined;
|
|
574
|
+
this.#remoteQueue.push({ message, sent: false, transport });
|
|
575
|
+
if (this.#streaming || transport === "followUp")
|
|
576
|
+
this.#flushRemote();
|
|
577
|
+
}
|
|
578
|
+
#flushRemote() {
|
|
579
|
+
void this.#syncModelSelection().finally(() => {
|
|
580
|
+
for (const entry of this.#remoteQueue) {
|
|
581
|
+
if (entry.sent)
|
|
582
|
+
continue;
|
|
583
|
+
entry.sent = true;
|
|
584
|
+
const text = userMessageText(entry.message);
|
|
585
|
+
const sent = entry.transport === "followUp" ? this.#client.followUp(text) : this.#client.steer(text);
|
|
586
|
+
trace(`-> ${entry.transport} "${text.slice(0, 40)}"`);
|
|
587
|
+
void sent.catch((error) => this.#fail(error));
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Push the effective model selection into the client before a turn is
|
|
593
|
+
* dispatched. The SDK has no live setModel, so the client stores the id
|
|
594
|
+
* and applies it as the next run's ThreadOption (via resumeThread, so the
|
|
595
|
+
* conversation context survives). The omp waterfall pair
|
|
596
|
+
* (system-prompt assemble → agent/request) is a V1 cut: the base is the
|
|
597
|
+
* session's logged `request/header` falling back to the global
|
|
598
|
+
* `agentDefaultModel` selection, else the agent options — the apiproxy's
|
|
599
|
+
* installed selection IS the global one this process serves.
|
|
600
|
+
*/
|
|
601
|
+
async #syncModelSelection() {
|
|
602
|
+
const persisted = this.session.requestHeader()?.config;
|
|
603
|
+
const service = this.ctx.get("agentDefaultModel");
|
|
604
|
+
const selection = service?.currentSelection();
|
|
605
|
+
const target = persisted !== undefined && persisted.provider !== "" && persisted.model !== ""
|
|
606
|
+
? { provider: persisted.provider, model: persisted.model }
|
|
607
|
+
: selection !== undefined && selection.provider !== "" && selection.model !== ""
|
|
608
|
+
? selection
|
|
609
|
+
: { provider: this.options.provider ?? "", model: this.options.model ?? "" };
|
|
610
|
+
if (target.provider === "" || target.model === "")
|
|
611
|
+
return;
|
|
612
|
+
const key = `${target.provider}/${target.model}`;
|
|
613
|
+
if (key === this.#lastSyncedModel)
|
|
614
|
+
return;
|
|
615
|
+
await this.#client.setModel(target.provider, target.model).catch((error) => {
|
|
616
|
+
trace(`set_model ${key} failed: ${String(error)}`);
|
|
617
|
+
});
|
|
618
|
+
this.#lastSyncedModel = key;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Reserve a turn WITHOUT appending anything: the prompt path reserves
|
|
622
|
+
* synchronously, then commits turn/start + user/message only after the
|
|
623
|
+
* cold start (#startTurn). #openTurn keeps the eager append for paths that
|
|
624
|
+
* run against an already-live client (#bridgeRemoteUser echoes).
|
|
625
|
+
*/
|
|
626
|
+
#reserveTurn() {
|
|
627
|
+
if (this.#turnOpen)
|
|
628
|
+
return;
|
|
629
|
+
this.#dashTurn = ++this.#lastTurn;
|
|
630
|
+
this.#step = 0;
|
|
631
|
+
this.#turnOpen = true;
|
|
632
|
+
this.#localUserPending = true;
|
|
633
|
+
}
|
|
634
|
+
#openTurn(message, localUser = true) {
|
|
635
|
+
trace(`#openTurn turnOpen=${this.#turnOpen} -> turn=${this.#dashTurn + 1} localUser=${localUser}`);
|
|
636
|
+
if (this.#turnOpen)
|
|
637
|
+
return;
|
|
638
|
+
this.#reserveTurn();
|
|
639
|
+
this.#localUserPending = localUser;
|
|
640
|
+
this.session.append("turn/start", { turn: this.#dashTurn });
|
|
641
|
+
this.#emitRequestContext();
|
|
642
|
+
this.session.append("user/message", message, { surfaceOp: "append" });
|
|
643
|
+
}
|
|
644
|
+
#closeTurn(reason) {
|
|
645
|
+
if (!this.#turnOpen)
|
|
646
|
+
return;
|
|
647
|
+
this.#turnOpen = false;
|
|
648
|
+
this.#localUserPending = false;
|
|
649
|
+
this.session.append("turn/end", { turn: this.#dashTurn, reason });
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Append the deferred `step/start` now that the step's first message has
|
|
653
|
+
* revealed which Dash turn owns it (a queued follow-up opens a fresh turn).
|
|
654
|
+
*/
|
|
655
|
+
#commitStepStart() {
|
|
656
|
+
if (!this.#turnOpen)
|
|
657
|
+
return;
|
|
658
|
+
// On-demand open: the Codex SDK emits `item.completed` without a preceding
|
|
659
|
+
// `item.started` for plain text turns, so a step must open at the first
|
|
660
|
+
// step-scoped use rather than only on a `message_start` that may never
|
|
661
|
+
// arrive. `#stepStartPending` (set by turn_start) still forces a FRESH
|
|
662
|
+
// step when the runtime opens another turn.
|
|
663
|
+
if (!this.#stepStartPending && this.#step > 0)
|
|
664
|
+
return;
|
|
665
|
+
this.#stepStartPending = false;
|
|
666
|
+
this.#step += 1;
|
|
667
|
+
this.session.append("step/start", { turn: this.#dashTurn, step: this.#step });
|
|
668
|
+
trace(`step/start turn=${this.#dashTurn} step=${this.#step}`);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Bridge a wire `message_start(role=user)` that was NOT appended locally.
|
|
672
|
+
* A `followUp` echo closes the current Dash turn and opens a fresh one with
|
|
673
|
+
* the retained message (the client queued it and is now generating its
|
|
674
|
+
* turn); a steering/injection echo adds a `user/message` step to the
|
|
675
|
+
* current turn. Falls back to the wire's own content if no retained
|
|
676
|
+
* message matches.
|
|
677
|
+
*/
|
|
678
|
+
#bridgeRemoteUser(wireMessage) {
|
|
679
|
+
const entry = this.#remoteQueue.shift();
|
|
680
|
+
if (entry === undefined) {
|
|
681
|
+
this.#closeTurn({ kind: "completed" });
|
|
682
|
+
this.#openTurn(userMessageFromWire(wireMessage), false);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (entry.transport === "followUp") {
|
|
686
|
+
this.#closeTurn({ kind: "completed" });
|
|
687
|
+
this.#openTurn(entry.message, false);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
this.session.append("user/message", entry.message, { surfaceOp: "append" });
|
|
691
|
+
}
|
|
692
|
+
#beginActivity() {
|
|
693
|
+
// Settle any prior in-flight activity first: `send()` can inject a prompt
|
|
694
|
+
// mid-stream (streamingBehavior followUp/steer), superseding the current
|
|
695
|
+
// activity promise. Without settling the old one, whenIdle()'s do-while
|
|
696
|
+
// would await an orphaned promise that never resolves.
|
|
697
|
+
this.#resolveActivityDone();
|
|
698
|
+
this.#activityDone = new Promise((resolve) => {
|
|
699
|
+
this.#resolveActivityDone = resolve;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
#endActivity() {
|
|
703
|
+
this.#resolveActivityDone();
|
|
704
|
+
}
|
|
705
|
+
#markRunning() {
|
|
706
|
+
clearTimeout(this.#idleExitTimer);
|
|
707
|
+
this.#idleExitTimer = undefined;
|
|
708
|
+
if (this.#streaming)
|
|
709
|
+
return;
|
|
710
|
+
this.#streaming = true;
|
|
711
|
+
this.#dispatch.emit("agent/status", { status: "running" });
|
|
712
|
+
}
|
|
713
|
+
#markIdle() {
|
|
714
|
+
if (!this.#streaming)
|
|
715
|
+
return;
|
|
716
|
+
this.#streaming = false;
|
|
717
|
+
this.#dispatch.emit("agent/status", { status: "idle" });
|
|
718
|
+
this.#armIdleExit();
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Schedule the idle exit. Skipped while deliveries are still queued (an
|
|
722
|
+
* unanswered parked injection means work is pending, not idle) or when the
|
|
723
|
+
* exit is disabled. Any later activity restarts or cancels the timer.
|
|
724
|
+
*/
|
|
725
|
+
#armIdleExit() {
|
|
726
|
+
if (this.#disposed || this.#onIdleExit === undefined || CODEX_IDLE_EXIT_MS === 0)
|
|
727
|
+
return;
|
|
728
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
729
|
+
return;
|
|
730
|
+
clearTimeout(this.#idleExitTimer);
|
|
731
|
+
this.#idleExitTimer = setTimeout(() => {
|
|
732
|
+
this.#idleExitTimer = undefined;
|
|
733
|
+
void this.#revalidateIdleExit();
|
|
734
|
+
}, CODEX_IDLE_EXIT_MS);
|
|
735
|
+
this.#idleExitTimer.unref?.();
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Quiescence re-validation at fire time. The idle timer is only a hint:
|
|
739
|
+
* before teardown, re-confirm no live work via local signals and the
|
|
740
|
+
* client's own self-attestation (get_state streaming). Any busy signal
|
|
741
|
+
* re-arms instead of killing; a query failure fail-softs to the local
|
|
742
|
+
* signals rather than forcing a teardown.
|
|
743
|
+
*/
|
|
744
|
+
async #revalidateIdleExit() {
|
|
745
|
+
if (this.#disposed || this.#streaming)
|
|
746
|
+
return this.#armIdleExit();
|
|
747
|
+
if (this.#remoteQueue.some((entry) => !entry.sent))
|
|
748
|
+
return this.#armIdleExit();
|
|
749
|
+
try {
|
|
750
|
+
const state = await this.#client.getState();
|
|
751
|
+
if (this.#streaming)
|
|
752
|
+
return this.#armIdleExit();
|
|
753
|
+
if (state.isStreaming)
|
|
754
|
+
return this.#armIdleExit();
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// Self-attestation unavailable: local signals remain authoritative.
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const subagents = await this.#client.getSubagents();
|
|
761
|
+
if (this.#streaming)
|
|
762
|
+
return this.#armIdleExit();
|
|
763
|
+
if (subagents.length > 0)
|
|
764
|
+
return this.#armIdleExit();
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
// getSubagents fail-softs already; belt-and-suspenders.
|
|
768
|
+
}
|
|
769
|
+
if (this.#streaming)
|
|
770
|
+
return this.#armIdleExit();
|
|
771
|
+
trace(`idle exit after ${CODEX_IDLE_EXIT_MS}ms — disposing agent ${this.id}`);
|
|
772
|
+
this.#onIdleExit?.();
|
|
773
|
+
}
|
|
774
|
+
#fail(error) {
|
|
775
|
+
this.#abandonStreamBridge();
|
|
776
|
+
if (this.#disposed)
|
|
777
|
+
return;
|
|
778
|
+
const classified = classifyNativeFailure(error);
|
|
779
|
+
trace(`#fail [${classified.code}] ${classified.message.slice(0, 200)}`);
|
|
780
|
+
if (classified.code === "ROLLOUT_MISSING" || classified.code === "NATIVE_REJECTED") {
|
|
781
|
+
// The native runtime rejected this session's thread at turn time.
|
|
782
|
+
// Mark the identity map so the next resume fails fast with the stable
|
|
783
|
+
// code instead of burning another turn on a dead rollout (plan RC-4).
|
|
784
|
+
this.#runtimeInfo?.markNotResumable?.();
|
|
785
|
+
}
|
|
786
|
+
// Flush a buffered final assistant (usage-less — the turn failed) so
|
|
787
|
+
// failure paths never drop the turn's visible text.
|
|
788
|
+
this.#flushDeferredAssistant();
|
|
789
|
+
this.#closeTurn({ kind: "error", error: classified });
|
|
790
|
+
this.#markIdle();
|
|
791
|
+
this.#endActivity();
|
|
792
|
+
}
|
|
793
|
+
#handleEvent(event) {
|
|
794
|
+
switch (event.type) {
|
|
795
|
+
case "agent_start":
|
|
796
|
+
trace("event agent_start");
|
|
797
|
+
this.#markRunning();
|
|
798
|
+
this.#flushRemote();
|
|
799
|
+
break;
|
|
800
|
+
case "turn_start":
|
|
801
|
+
// Defer `step/start`: the step's owning turn is only known once its
|
|
802
|
+
// first message arrives (a queued follow-up opens a fresh turn).
|
|
803
|
+
trace("event turn_start");
|
|
804
|
+
this.#stepStartPending = true;
|
|
805
|
+
this.#pendingFailure = null;
|
|
806
|
+
// Open the step immediately (the SDK may never send message_start)
|
|
807
|
+
// and try the system prompt now that the thread's rollout exists.
|
|
808
|
+
this.#commitStepStart();
|
|
809
|
+
this.#emitSystemMessage();
|
|
810
|
+
break;
|
|
811
|
+
case "message_start": {
|
|
812
|
+
const message = event.message;
|
|
813
|
+
if (message?.role === "user") {
|
|
814
|
+
if (this.#localUserPending) {
|
|
815
|
+
// The client is echoing the user message #openTurn already appended.
|
|
816
|
+
this.#localUserPending = false;
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
// A queued follow-up / steering message delivered by the client.
|
|
820
|
+
this.#bridgeRemoteUser(message);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
else if (message?.role === "assistant") {
|
|
824
|
+
// Reset per-message streaming accumulators.
|
|
825
|
+
this.#abandonStreamBridge();
|
|
826
|
+
}
|
|
827
|
+
// role === "toolResult" is bridged in message_end (deduped vs tool_execution_end).
|
|
828
|
+
this.#commitStepStart();
|
|
829
|
+
// The Codex system prompt is stamped inside the first OPEN step of the
|
|
830
|
+
// first turn (the rollout HEAD only exists once the thread started).
|
|
831
|
+
this.#emitSystemMessage();
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case "message_update": {
|
|
835
|
+
const delta = event.assistantMessageEvent;
|
|
836
|
+
if (delta !== undefined)
|
|
837
|
+
this.#handleUpdate(delta);
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case "message_end": {
|
|
841
|
+
const message = event.message;
|
|
842
|
+
if (message === undefined)
|
|
843
|
+
break;
|
|
844
|
+
const role = message.role;
|
|
845
|
+
if (role === "assistant") {
|
|
846
|
+
const failure = wireFailure(message);
|
|
847
|
+
if (failure !== undefined) {
|
|
848
|
+
this.#pendingFailure = failure;
|
|
849
|
+
this.#abandonStreamBridge();
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
// Buffer as the turn's final assistant candidate: flushed at
|
|
853
|
+
// `turn_end` with the SAME turn's usage (or earlier on failure).
|
|
854
|
+
this.#deferAssistant(message);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
else if (role === "toolResult") {
|
|
858
|
+
this.#appendToolResultMessage(message);
|
|
859
|
+
}
|
|
860
|
+
else if (role !== "user") {
|
|
861
|
+
process.stderr.write(`codex wire: unhandled message_end role "${role}"\n`);
|
|
862
|
+
}
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case "tool_execution_start": {
|
|
866
|
+
const callId = String(event.toolCallId ?? "");
|
|
867
|
+
const name = String(event.toolName ?? "");
|
|
868
|
+
this.#commitStepStart();
|
|
869
|
+
this.session.append("tool/call", {
|
|
870
|
+
turn: this.#dashTurn,
|
|
871
|
+
step: this.#step,
|
|
872
|
+
callId: ToolCallId(callId),
|
|
873
|
+
name,
|
|
874
|
+
// The DSH payload requires the RAW JSON string the model produced;
|
|
875
|
+
// the wire carries it verbatim for MCP calls and renders it from the
|
|
876
|
+
// discrete fields for the other item shapes.
|
|
877
|
+
arguments: String(event.argumentsJson ?? JSON.stringify(event.args ?? {})),
|
|
878
|
+
});
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case "tool_execution_end":
|
|
882
|
+
this.#appendToolResult(event);
|
|
883
|
+
break;
|
|
884
|
+
case "todo_write":
|
|
885
|
+
// DSH `todo/write` (log-only, latest-wins whole-list snapshot) —
|
|
886
|
+
// projected by the todo_list tool item alongside its tool pair.
|
|
887
|
+
// The locally-resolved dsh-session build does not declare the
|
|
888
|
+
// tool-todo vocabulary, so the append goes through a narrow cast
|
|
889
|
+
// (same seam as the model/selection append in index.ts).
|
|
890
|
+
// The locally-resolved dsh-session build does not declare the
|
|
891
|
+
// tool-todo vocabulary (no dsh-tool-todo dependency), so the append
|
|
892
|
+
// goes through a narrow cast (same seam as model/selection in index.ts).
|
|
893
|
+
this.session.append("todo/write", {
|
|
894
|
+
todos: Array.isArray(event.todos) ? event.todos : [],
|
|
895
|
+
});
|
|
896
|
+
break;
|
|
897
|
+
case "turn_end":
|
|
898
|
+
trace("event turn_end");
|
|
899
|
+
// Last chance for the system prompt: by now the thread's rollout HEAD
|
|
900
|
+
// exists for certain. Appended inside the still-open step (before
|
|
901
|
+
// step/end) so the step-scope invariant holds; a no-op once stamped.
|
|
902
|
+
this.#emitSystemMessage();
|
|
903
|
+
// Land THIS turn's usage on the turn's final assistant message — the
|
|
904
|
+
// DSH turn-token fold requires the sample on a message of the SAME
|
|
905
|
+
// turn. Flushed inside the still-open step, BEFORE step/end.
|
|
906
|
+
this.#flushDeferredAssistant(convertUsage(event.data?.usage));
|
|
907
|
+
if (this.#turnOpen && this.#step > 0) {
|
|
908
|
+
this.session.append("step/end", { turn: this.#dashTurn, step: this.#step });
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
case "agent_end": {
|
|
912
|
+
trace(`event agent_end cancelCause=${String(this.#cancelCause)}`);
|
|
913
|
+
const pendingFailure = this.#pendingFailure;
|
|
914
|
+
this.#pendingFailure = null;
|
|
915
|
+
// Backstop flush: aborted/failed turns never saw `turn_end`, so the
|
|
916
|
+
// buffered final assistant flushes here (usage-less) and the visible
|
|
917
|
+
// text survives — the pre-deferral behavior, re-ordered.
|
|
918
|
+
this.#flushDeferredAssistant();
|
|
919
|
+
this.#closeTurn(this.#cancelCause !== null
|
|
920
|
+
? { kind: "aborted", reason: this.#cancelCause }
|
|
921
|
+
: pendingFailure !== null
|
|
922
|
+
? { kind: "error", error: pendingFailure }
|
|
923
|
+
: { kind: "completed" });
|
|
924
|
+
this.#cancelCause = null;
|
|
925
|
+
this.#markIdle();
|
|
926
|
+
this.#endActivity();
|
|
927
|
+
// Live metadata the Dash surface has no projection seam for yet:
|
|
928
|
+
// surface the client's own accounting in the diagnostic trace so it
|
|
929
|
+
// is observable per completed turn.
|
|
930
|
+
void this.#client
|
|
931
|
+
.getSessionStats()
|
|
932
|
+
.then((stats) => {
|
|
933
|
+
trace(`sessionStats tokens=${JSON.stringify(stats.tokens ?? {})}`);
|
|
934
|
+
})
|
|
935
|
+
.catch(() => { });
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** The open stream bridge for this message, starting one on first use. */
|
|
941
|
+
#ensureStreamBridge() {
|
|
942
|
+
if (this.#streamBridge === undefined || this.#streamBridge.ended) {
|
|
943
|
+
this.#streamBridge = new AssistantStreamBridge(this.session.id, ++this.#assistantAttemptCounter, () => ++this.#assistantStreamRevision, (frame) => this.#dispatch.emit("agent/assistant-stream", { frame }));
|
|
944
|
+
this.#streamBridge.start(this.#dashTurn, this.#step);
|
|
945
|
+
}
|
|
946
|
+
return this.#streamBridge;
|
|
947
|
+
}
|
|
948
|
+
/** Abandon any open stream bridge — failure paths never settle. */
|
|
949
|
+
#abandonStreamBridge() {
|
|
950
|
+
if (this.#streamBridge !== undefined && !this.#streamBridge.ended)
|
|
951
|
+
this.#streamBridge.abandon();
|
|
952
|
+
this.#streamBridge = undefined;
|
|
953
|
+
}
|
|
954
|
+
#handleUpdate(delta) {
|
|
955
|
+
switch (delta.type) {
|
|
956
|
+
case "text_delta":
|
|
957
|
+
if (typeof delta.delta === "string" && delta.delta !== "") {
|
|
958
|
+
const chunk = { type: "text-delta", index: delta.contentIndex ?? 0, text: delta.delta };
|
|
959
|
+
// v2: live text no longer appends `assistant/chunk` log events — the
|
|
960
|
+
// stream publishes as dense agent frames and embeds durably in the
|
|
961
|
+
// final assistant/message.
|
|
962
|
+
this.#ensureStreamBridge().push(chunk);
|
|
963
|
+
}
|
|
964
|
+
break;
|
|
965
|
+
default:
|
|
966
|
+
// thinking_*/toolcall_* are folded into the final message.
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Buffer the turn's final assistant message. An already-buffered message is
|
|
972
|
+
* flushed first (usage-less — it belonged to an earlier codex turn of the
|
|
973
|
+
* same Dash turn), so the buffer always holds the LATEST candidate.
|
|
974
|
+
*/
|
|
975
|
+
#deferAssistant(message) {
|
|
976
|
+
this.#flushDeferredAssistant();
|
|
977
|
+
this.#deferredAssistant = message;
|
|
978
|
+
}
|
|
979
|
+
/** Append the deferred final assistant message (with `usage` when known); no-op when empty. */
|
|
980
|
+
#flushDeferredAssistant(usage) {
|
|
981
|
+
const message = this.#deferredAssistant;
|
|
982
|
+
if (message === null)
|
|
983
|
+
return;
|
|
984
|
+
this.#deferredAssistant = null;
|
|
985
|
+
this.#appendAssistantMessage(message, usage);
|
|
986
|
+
}
|
|
987
|
+
#appendAssistantMessage(message, usage) {
|
|
988
|
+
const content = convertContent(message.content);
|
|
989
|
+
const assistant = createAssistantMessage({
|
|
990
|
+
content,
|
|
991
|
+
source: {
|
|
992
|
+
// Rollout/wire messages may carry no attribution: fall back to this
|
|
993
|
+
// agent's options (the omp bridge did the same).
|
|
994
|
+
provider: String(message.provider ?? this.options.provider ?? ""),
|
|
995
|
+
model: String(message.model ?? this.options.model ?? ""),
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
const bridge = this.#streamBridge;
|
|
999
|
+
this.#commitStepStart();
|
|
1000
|
+
// v2: the attempt's timed chunks embed in the event itself;
|
|
1001
|
+
// `sourceEventSeqs` is forbidden on assistant/message in v2.
|
|
1002
|
+
const event = this.session.append("assistant/message", {
|
|
1003
|
+
turn: this.#dashTurn,
|
|
1004
|
+
step: this.#step,
|
|
1005
|
+
message: assistant,
|
|
1006
|
+
stream: bridge?.records ?? [],
|
|
1007
|
+
...(usage === undefined ? {} : { usage }),
|
|
1008
|
+
}, {
|
|
1009
|
+
surfaceOp: "append",
|
|
1010
|
+
});
|
|
1011
|
+
if (bridge !== undefined && !bridge.ended)
|
|
1012
|
+
bridge.settle(event.seq);
|
|
1013
|
+
this.#streamBridge = undefined;
|
|
1014
|
+
}
|
|
1015
|
+
#appendToolResult(event) {
|
|
1016
|
+
const callId = String(event.toolCallId ?? "");
|
|
1017
|
+
this.#bridgedToolResults.add(callId);
|
|
1018
|
+
const result = event.result;
|
|
1019
|
+
const isError = Boolean(event.isError ?? result?.isError ?? false);
|
|
1020
|
+
const message = createToolResultMessage({
|
|
1021
|
+
callId: ToolCallId(callId),
|
|
1022
|
+
content: convertContent(result?.content),
|
|
1023
|
+
isError,
|
|
1024
|
+
});
|
|
1025
|
+
this.session.append("tool/result", {
|
|
1026
|
+
turn: this.#dashTurn,
|
|
1027
|
+
step: this.#step,
|
|
1028
|
+
message,
|
|
1029
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
1030
|
+
}, {
|
|
1031
|
+
surfaceOp: "append",
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
#appendToolResultMessage(message) {
|
|
1035
|
+
const callId = String(message.toolCallId ?? "");
|
|
1036
|
+
// The normal path bridges via `tool_execution_end` first; skip the
|
|
1037
|
+
// `message_end(toolResult)` duplicate the wire always also emits.
|
|
1038
|
+
if (this.#bridgedToolResults.has(callId))
|
|
1039
|
+
return;
|
|
1040
|
+
this.#bridgedToolResults.add(callId);
|
|
1041
|
+
const isError = Boolean(message.isError ?? false);
|
|
1042
|
+
const result = createToolResultMessage({
|
|
1043
|
+
callId: ToolCallId(callId),
|
|
1044
|
+
content: convertContent(message.content),
|
|
1045
|
+
isError,
|
|
1046
|
+
});
|
|
1047
|
+
this.session.append("tool/result", {
|
|
1048
|
+
turn: this.#dashTurn,
|
|
1049
|
+
step: this.#step,
|
|
1050
|
+
message: result,
|
|
1051
|
+
...(isError ? { error: { name: "ToolExecutionError", code: "TOOL_ERROR" } } : {}),
|
|
1052
|
+
}, {
|
|
1053
|
+
surfaceOp: "append",
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|