dsh-agy-cli 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +31 -0
  2. package/README.en.md +48 -0
  3. package/README.md +67 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +445 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +940 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +54 -0
  56. package/lib/index.js +267 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +994 -0
  91. package/package.json +115 -4
@@ -0,0 +1,2159 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute, parse as parsePath, relative, resolve } from 'node:path';
4
+ import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy, ToolCallId, } from '@deepseek-ai/dsh-llm';
5
+ import { ExperimentalAgyTransport, PersistentTransportError, } from '../agy/persistent-transport.js';
6
+ import { AgyProcessError, agyProcessDiagnosticOf, isAgyReasoningEffort, runAgyProcess, } from '../agy/process.js';
7
+ import { getAgentPreset } from '../agent-presets.js';
8
+ import { AgyModelDiscovery, } from '../agy/models.js';
9
+ import { diagnoseAgy } from '../agy/diagnostics.js';
10
+ import { AgyConcurrencyLimiter, AgyQueueError } from '../agy/limiter.js';
11
+ import { buildAgyLogRecord, emitAgyLog, } from '../agy/log.js';
12
+ import { classifyAgyFailure, safeAgyFailureMessage } from '../agy/errors.js';
13
+ import { readAgyRuntimeOverrides } from '../agy/overrides.js';
14
+ import { AgyParserError, AgyStreamParser, conversationIdOf, emptyAgyEventCategoryCounts, errorDetailOf, eventCategoryOf, finalResultRawOf, isToolEvent, isDshCarrierRecipient, isPermissionEvent, responseOf, sendMessageOf, statusOf, stepTypeOf, textDeltaOf, toolEventDiagnosticOf, toolEventKindOf, toolNameOf, usageOf, } from '../agy/parser.js';
15
+ import { resolveAgyExecutable } from '../agy/process.js';
16
+ import { AGY_RETRYABLE_CODES, configuredModels, DEFAULT_AGENT, DEFAULT_MODEL, extractModelEffort, filterVisibleModels, normalizeModelId, } from './config.js';
17
+ import { AGY_INTERNAL_TOOL_EVENT_CODE, DSH_CONTEXT_UNAVAILABLE_CODE, PERMISSION_REQUIRED_CODE, UNSUPPORTED_REASONING_EFFORT_CODE, UNSUPPORTED_TOOLS_CODE, } from './error-codes.js';
18
+ import { SessionRegistry } from '../session/store.js';
19
+ import { AgyPromptError, serializeAgyPrompt, serializeAgyTurnPrompt } from './serialize.js';
20
+ import { appendToolProtocolPrompt, appendToolProtocolRepairPrompt, createStructuredToolProtocol, parseStructuredEnvelope, TOOL_PROTOCOL_ARGUMENTS_INVALID_CODE, TOOL_PROTOCOL_RESPONSE_LIMIT_CODE, ToolProtocolError, TOOL_PROTOCOL_RESPONSE_INVALID_CODE, TOOL_PROTOCOL_UNKNOWN_TOOL_CODE, } from './tool-protocol.js';
21
+ import { AgyPromptBudgetError, boundAgyPrompt, DEFAULT_AGY_PROMPT_CONTENT_LIMIT_BYTES, DEFAULT_DSH_TOOL_PROMPT_CONTENT_LIMIT_BYTES, DEFAULT_INPUT_FRAME_LIMIT_BYTES, DEFAULT_MAX_HISTORICAL_TOOL_RESULT_BYTES, DEFAULT_MAX_SINGLE_TOOL_RESULT_BYTES, stableAgyPromptPrefix, } from './prompt-budget.js';
22
+ import { AgyImageBridgeError, prepareAgyPrompts, } from './image-bridge.js';
23
+ import { DshContextError, resolveDshContext, } from '../dsh/context.js';
24
+ class AsyncQueue {
25
+ values = [];
26
+ waiters = [];
27
+ closed = false;
28
+ failure;
29
+ push(value) {
30
+ if (this.closed)
31
+ return;
32
+ const waiter = this.waiters.shift();
33
+ if (waiter === undefined)
34
+ this.values.push(value);
35
+ else
36
+ waiter.resolve({ done: false, value });
37
+ }
38
+ end() {
39
+ if (this.closed)
40
+ return;
41
+ this.closed = true;
42
+ while (this.waiters.length > 0) {
43
+ this.waiters.shift()?.resolve({ done: true, value: undefined });
44
+ }
45
+ }
46
+ fail(error) {
47
+ if (this.closed)
48
+ return;
49
+ this.closed = true;
50
+ this.failure = error;
51
+ while (this.waiters.length > 0) {
52
+ this.waiters.shift()?.reject(error);
53
+ }
54
+ }
55
+ next() {
56
+ const value = this.values.shift();
57
+ if (value !== undefined)
58
+ return Promise.resolve({ done: false, value });
59
+ if (this.failure !== undefined)
60
+ return Promise.reject(this.failure);
61
+ if (this.closed)
62
+ return Promise.resolve({ done: true, value: undefined });
63
+ return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
64
+ }
65
+ [Symbol.asyncIterator]() {
66
+ return this;
67
+ }
68
+ }
69
+ const DEFAULT_MINIMUM_AGY_VERSION = '1.1.15';
70
+ const DEFAULT_MAX_CONCURRENT = 4;
71
+ const DEFAULT_MAX_QUEUE = 32;
72
+ const DEFAULT_QUEUE_TIMEOUT_MS = 30_000;
73
+ const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
74
+ const DEFAULT_MAX_EVENT_LINE_LENGTH = 1_048_576;
75
+ const IMAGE_TOOL_PATH_KEYS = new Set(['AbsolutePath', 'absolutePath', 'path', 'file_path', 'filePath']);
76
+ const IMAGE_TOOL_JSON_KEYS = new Set(['args', 'arguments', 'input', 'parameters', 'tool_input', 'toolInput']);
77
+ function isRecord(value) {
78
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
79
+ }
80
+ function unwrapToolPath(value) {
81
+ let current = value.trim();
82
+ for (let attempt = 0; attempt < 2; attempt += 1) {
83
+ if (!(current.startsWith('"') && current.endsWith('"')))
84
+ break;
85
+ try {
86
+ const decoded = JSON.parse(current);
87
+ if (typeof decoded !== 'string')
88
+ break;
89
+ current = decoded.trim();
90
+ }
91
+ catch {
92
+ current = current.slice(1, -1).trim();
93
+ }
94
+ }
95
+ return current;
96
+ }
97
+ function imageToolPathCandidates(event) {
98
+ const candidates = [];
99
+ const visit = (value, key, depth) => {
100
+ if (depth > 8)
101
+ return;
102
+ if (typeof value === 'string') {
103
+ if (key !== undefined && IMAGE_TOOL_PATH_KEYS.has(key)) {
104
+ candidates.push(unwrapToolPath(value));
105
+ return;
106
+ }
107
+ if (key !== undefined && IMAGE_TOOL_JSON_KEYS.has(key)) {
108
+ try {
109
+ visit(JSON.parse(value), undefined, depth + 1);
110
+ }
111
+ catch {
112
+ // Some lifecycle events intentionally omit structured tool arguments.
113
+ }
114
+ }
115
+ return;
116
+ }
117
+ if (Array.isArray(value)) {
118
+ for (const entry of value)
119
+ visit(entry, undefined, depth + 1);
120
+ return;
121
+ }
122
+ if (!isRecord(value))
123
+ return;
124
+ for (const [childKey, child] of Object.entries(value))
125
+ visit(child, childKey, depth + 1);
126
+ };
127
+ visit(event, undefined, 0);
128
+ return candidates;
129
+ }
130
+ function samePath(left, right) {
131
+ const a = resolve(left);
132
+ const b = resolve(right);
133
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
134
+ }
135
+ function isInsideDirectory(directory, selected) {
136
+ const rel = relative(resolve(directory), selected);
137
+ return !rel.startsWith('..') && !isAbsolute(rel);
138
+ }
139
+ function isExpectedImagePath(candidate, prepared) {
140
+ if (!isAbsolute(candidate) || !prepared.bridgeActive)
141
+ return false;
142
+ const selected = resolve(candidate);
143
+ const allowed = [prepared.imageDirectory, prepared.imageCacheDirectory].some(directory => directory !== undefined && isInsideDirectory(directory, selected));
144
+ if (!allowed)
145
+ return false;
146
+ return prepared.imagePaths.some(expected => samePath(expected, selected));
147
+ }
148
+ function isAllowedImageToolEvent(event, prepared, imageRuntime) {
149
+ if (!prepared.bridgeActive
150
+ || imageRuntime?.agentPreset !== 'image-view'
151
+ || imageRuntime.agent !== 'dsh-agy-image-view')
152
+ return false;
153
+ const toolName = toolNameOf(event);
154
+ if (toolName !== undefined && toolName !== 'view_file')
155
+ return false;
156
+ if (toolName === undefined && event.event !== 'tool_result')
157
+ return false;
158
+ const candidates = imageToolPathCandidates(event);
159
+ return candidates.length === 0 || candidates.every(candidate => isExpectedImagePath(candidate, prepared));
160
+ }
161
+ /**
162
+ * AGY may expose its own task-orchestration lifecycle as a step_update tool
163
+ * event even when the selected AGY Agent is tool-free. `manage_task` is an
164
+ * AGY-internal bookkeeping event: it never becomes a DSH tool call and its
165
+ * input is never executed by this provider. Keep this allowlist deliberately
166
+ * narrow; every other AGY tool event remains fail-closed below.
167
+ */
168
+ function isIgnorableAgyOrchestrationToolEvent(event) {
169
+ return event.event === 'step_update'
170
+ && stepTypeOf(event) === 'tool'
171
+ && toolNameOf(event) === 'manage_task';
172
+ }
173
+ /** Read the MCP tool identity out of an AGY `call_mcp_tool` step. */
174
+ function mcpToolStepOf(event) {
175
+ if (event.event !== 'step_update')
176
+ return undefined;
177
+ const step = event.step_update;
178
+ if (step === undefined || step.step_type !== 'tool')
179
+ return undefined;
180
+ const info = step.tool_info;
181
+ if (typeof info !== 'object' || info === null)
182
+ return undefined;
183
+ const record = info;
184
+ if (record.name !== 'call_mcp_tool')
185
+ return undefined;
186
+ const parameters = record.parameters;
187
+ if (typeof parameters !== 'object' || parameters === null)
188
+ return undefined;
189
+ const args = parameters;
190
+ // AGY nests the real call under `Arguments`; the server is our own bridge.
191
+ const toolName = typeof args.ToolName === 'string' ? args.ToolName : undefined;
192
+ if (toolName === undefined || toolName.length === 0)
193
+ return undefined;
194
+ const stepIndex = typeof step.step_index === 'number' ? step.step_index : -1;
195
+ return { stepIndex, toolName, arguments: args.Arguments ?? {} };
196
+ }
197
+ /**
198
+ * One bridged MCP step rendered as a progress line for the reasoning channel.
199
+ *
200
+ * Deliberately NOT a DSH tool-call block. The host commits the assistant
201
+ * message, then executes every `tool-call` block it contains and starts another
202
+ * step (`@deepseek-ai/dsh-agent-loop` — `message.content.filter(block =>
203
+ * block.type === "tool-call")` … `executeToolCalls(...)`). The bridge has
204
+ * already run the tool, so advertising it as a call executes it a second time,
205
+ * re-asks for approval, and makes AGY answer the same work again — the
206
+ * "content arrived, vanished, then arrived several times over" report. A
207
+ * reasoning line keeps the step visible in the transcript without handing the
208
+ * host a call to replay.
209
+ *
210
+ * For Scheme B (规范化过程流 Step Process), each bridged step is emitted as a
211
+ * structured, human-readable Chinese status line into the reasoning channel,
212
+ * with commands/paths safely enclosed in backticks (`code`) to protect against
213
+ * browser auto-translation plugins. DSH Desktop and Pocket display this as the
214
+ * live progress indicator during execution and collapse it into the process
215
+ * drawer once completed, keeping the final text response 100% clean and concise.
216
+ */
217
+ // The provider streams process lines once each (the host's reasoning text is
218
+ // append-only). The host's own collapsed-summary logic
219
+ // (`latestCompletedParagraphFirstLine`) splits on BLANK lines and shows the
220
+ // first line of the latest completed paragraph while the row is running — that
221
+ // is exactly the "fold shows only the newest step, live" behavior the user
222
+ // asked for. So each step must be its OWN paragraph: a blank line separates
223
+ // consecutive steps. A single leading banner line is emitted first; when the
224
+ // row settles, the host's summary falls back to that line (the banner) as the
225
+ // one-line collapsed view.
226
+ function cleanStepTarget(str, maxLen = 100) {
227
+ const singleLine = str.trim().split(/\r?\n/)[0]?.trim() ?? '';
228
+ if (!singleLine)
229
+ return '';
230
+ const safe = singleLine.replace(/`/g, "'");
231
+ return safe.length > maxLen ? `${safe.slice(0, maxLen)}…` : safe;
232
+ }
233
+ /** One process step as its own markdown paragraph (blank line = separator). */
234
+ function mcpStepProcessLine(step, state) {
235
+ const isError = state === 'ERROR';
236
+ const statusIcon = isError ? '❌' : '🛠️';
237
+ const rawArgs = step.arguments ?? {};
238
+ const args = (typeof rawArgs === 'object' && rawArgs !== null ? rawArgs : {});
239
+ const pick = (...keys) => {
240
+ for (const k of keys) {
241
+ const v = args[k];
242
+ if (typeof v === 'string' && v !== '')
243
+ return v;
244
+ }
245
+ return '';
246
+ };
247
+ let line;
248
+ if (step.toolName === 'pwsh' || step.toolName === 'bash') {
249
+ const verb = isError ? '执行失败' : '执行';
250
+ const cmd = cleanStepTarget(typeof args.command === 'string' ? args.command : '');
251
+ line = cmd ? `${statusIcon} [${step.toolName}] ${verb}: \`${cmd}\`` : `${statusIcon} [${step.toolName}] ${verb}`;
252
+ }
253
+ else if (step.toolName === 'read' || step.toolName === 'write' || step.toolName === 'edit') {
254
+ const verbBase = step.toolName === 'read' ? '读取' : step.toolName === 'write' ? '写入' : '编辑';
255
+ const verb = isError ? `${verbBase}失败` : verbBase;
256
+ const p = cleanStepTarget(pick('file_path', 'path'), 120);
257
+ line = p ? `${statusIcon} [${step.toolName}] ${verb}: \`${p}\`` : `${statusIcon} [${step.toolName}] ${verb}`;
258
+ }
259
+ else if (step.toolName === 'grep') {
260
+ const verb = isError ? '检索失败' : '检索';
261
+ const pat = cleanStepTarget(pick('pattern'));
262
+ line = pat ? `${statusIcon} [grep] ${verb}: \`${pat}\`` : `${statusIcon} [grep] ${verb}`;
263
+ }
264
+ else if (step.toolName === 'glob') {
265
+ const verb = isError ? '查找失败' : '查找';
266
+ const pat = cleanStepTarget(pick('pattern', 'include'));
267
+ line = pat ? `${statusIcon} [glob] ${verb}: \`${pat}\`` : `${statusIcon} [glob] ${verb}`;
268
+ }
269
+ else {
270
+ const verb = isError ? '执行失败' : '执行';
271
+ const t = cleanStepTarget(pick('description', 'toolSummary', 'toolAction', 'query', 'url', 'path', 'file_path', 'command'));
272
+ line = t ? `${statusIcon} [${step.toolName}] ${verb}: \`${t}\`` : `${statusIcon} [${step.toolName}] ${verb}`;
273
+ }
274
+ return `${line}\n\n`;
275
+ }
276
+ /**
277
+ * The consolidated process block opens with a banner paragraph. The host's
278
+ * collapsed summary for a running row is `latestCompletedParagraphFirstLine`
279
+ * — the first line of the latest completed paragraph — so, with one blank-line
280
+ * separated paragraph per step, the fold live-rolls to the newest step; when
281
+ * the row settles, the summary falls back to `firstLine`, i.e. this banner,
282
+ * giving a stable one-line view of the finished process.
283
+ */
284
+ function reasoningBanner() {
285
+ return '[agy 过程流]\n\n';
286
+ }
287
+ /**
288
+ * The block closes with a total-step paragraph, appended after the last step's
289
+ * blank-line separator. It is not the settled summary (that is the banner) but
290
+ * it carries the count the reader needs to judge a stuck, looping turn at a
291
+ * glance when the body is expanded.
292
+ */
293
+ function reasoningCloseLine(stepCount) {
294
+ return `〔共 ${stepCount} 步〕\n`;
295
+ }
296
+ function stepStateOf(event) {
297
+ if (event.event !== 'step_update')
298
+ return undefined;
299
+ const step = event.step_update;
300
+ const state = step?.state;
301
+ return typeof state === 'string' ? state : undefined;
302
+ }
303
+ function dshOwnedToolEventError(event, toolProtocol, imageContext, mcpToolsEnabled = false) {
304
+ // Under the MCP bridge AGY is *supposed* to emit tool events: every
305
+ // call_mcp_tool step is a DSH tool call travelling over the bridge, so the
306
+ // internal-tool rejection only applies to the prompt-contract path.
307
+ if (mcpToolsEnabled && mcpToolStepOf(event) !== undefined)
308
+ return undefined;
309
+ if (toolProtocol === undefined || !isToolEvent(event))
310
+ return undefined;
311
+ if (imageContext !== undefined
312
+ && isAllowedImageToolEvent(event, imageContext.prepared, imageContext.runtime))
313
+ return undefined;
314
+ if (isIgnorableAgyOrchestrationToolEvent(event))
315
+ return undefined;
316
+ return new LlmError('AGY emitted an internal tool event while DSH-owned tools were enabled', AGY_INTERNAL_TOOL_EVENT_CODE);
317
+ }
318
+ /**
319
+ * AGY's main-agent wrapper may deliver a DSH tool envelope through its
320
+ * orchestration-only send_message primitive. Accept only the default_api
321
+ * carrier or a carrier addressed to the active root conversation, and only
322
+ * after validating the payload against the current DSH protocol. Every other
323
+ * internal event remains rejected by dshOwnedToolEventError. AGY 1.1.x can
324
+ * address the same DSH-owned carrier through one of its stable DSH runtime
325
+ * recipients (`dsh`, `dsh-session`, or `dsh-runner`) instead of a UUID.
326
+ */
327
+ function dshOwnedCarrierResponseOf(event, toolProtocol, activeConversationId, telemetry) {
328
+ if (toolProtocol === undefined)
329
+ return undefined;
330
+ const carrier = sendMessageOf(event);
331
+ if (carrier === undefined)
332
+ return undefined;
333
+ const isDefaultApiCarrier = carrier.recipient === 'default_api';
334
+ const isDshRuntimeCarrier = isDshCarrierRecipient(carrier.recipient);
335
+ const isSelfConversationCarrier = activeConversationId !== undefined
336
+ && carrier.recipient === activeConversationId;
337
+ if (!isDefaultApiCarrier && !isDshRuntimeCarrier && !isSelfConversationCarrier) {
338
+ telemetry.carrierValidation = 'recipient-rejected';
339
+ return undefined;
340
+ }
341
+ // Validation is deliberately performed here, before the carrier is
342
+ // converted into a normal DSH result. This prevents arbitrary AGY messages
343
+ // from becoming tool calls.
344
+ try {
345
+ const envelope = parseStructuredEnvelope(carrier.message, toolProtocol, {
346
+ onCompatibilityApplied: compatibility => {
347
+ telemetry.protocolCompatibilityApplied = compatibility;
348
+ },
349
+ });
350
+ telemetry.carrierValidation = envelope.kind === 'message' ? 'valid-message' : 'valid-tool-call';
351
+ }
352
+ catch (error) {
353
+ telemetry.carrierValidation = carrierValidationOf(error);
354
+ recordProtocolFailure(telemetry, carrier.message, error);
355
+ throw error;
356
+ }
357
+ return carrier.message;
358
+ }
359
+ function carrierValidationOf(error) {
360
+ if (!(error instanceof ToolProtocolError))
361
+ return 'invalid-envelope';
362
+ switch (error.code) {
363
+ case TOOL_PROTOCOL_UNKNOWN_TOOL_CODE:
364
+ return 'unknown-tool';
365
+ case TOOL_PROTOCOL_ARGUMENTS_INVALID_CODE:
366
+ return 'arguments-invalid';
367
+ case TOOL_PROTOCOL_RESPONSE_LIMIT_CODE:
368
+ return 'response-limit';
369
+ case TOOL_PROTOCOL_RESPONSE_INVALID_CODE:
370
+ default:
371
+ return 'invalid-envelope';
372
+ }
373
+ }
374
+ function recordToolEvent(telemetry, event, activeConversationId) {
375
+ const kind = toolEventKindOf(event);
376
+ if (kind === undefined)
377
+ return;
378
+ telemetry.toolEventCount += 1;
379
+ telemetry.toolEventKind ??= kind;
380
+ telemetry.toolEventStreamIndex = telemetry.eventCount;
381
+ telemetry.toolEventDiagnostic = toolEventDiagnosticOf(event, activeConversationId);
382
+ }
383
+ function canonicalWorkspaceRoot(value) {
384
+ const selected = value?.trim();
385
+ if (selected === undefined || selected.length === 0)
386
+ return undefined;
387
+ try {
388
+ const canonical = realpathSync(selected);
389
+ if (!statSync(canonical).isDirectory() || canonical === parsePath(canonical).root) {
390
+ throw new Error('workspace root must be a non-root directory');
391
+ }
392
+ return canonical;
393
+ }
394
+ catch (error) {
395
+ throw new RangeError(`workspaceRoot must point to an existing non-root directory: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
396
+ }
397
+ }
398
+ function resolveAgyAgentRuntime(config, toolPolicy) {
399
+ const dshOwned = toolPolicy === 'dsh-owned';
400
+ const configuredPreset = config.agentPreset === undefined ? undefined : getAgentPreset(config.agentPreset);
401
+ if (config.agentPreset !== undefined && configuredPreset === undefined) {
402
+ throw new RangeError('agentPreset must be one of: tool-free, read-only, workspace-write');
403
+ }
404
+ const preset = dshOwned
405
+ ? getAgentPreset('mcp-executor')
406
+ : configuredPreset;
407
+ const workspaceRoot = dshOwned ? undefined : canonicalWorkspaceRoot(config.workspaceRoot);
408
+ if (preset?.writeAccess === true && workspaceRoot === undefined) {
409
+ throw new RangeError('workspaceRoot is required when agentPreset is workspace-write');
410
+ }
411
+ return {
412
+ agent: preset?.agentName ?? config.agent ?? DEFAULT_AGENT,
413
+ agentPreset: preset?.id,
414
+ agentCanViewFile: preset?.tools.includes('view_file') === true,
415
+ workspaceRoot,
416
+ addDirs: workspaceRoot === undefined ? undefined : [workspaceRoot],
417
+ mode: preset?.mode,
418
+ disableSlashCommands: preset === undefined ? undefined : true,
419
+ };
420
+ }
421
+ const AGY_REASONING_METADATA = [
422
+ {
423
+ id: 'low',
424
+ name: 'Low',
425
+ },
426
+ {
427
+ id: 'medium',
428
+ name: 'Medium',
429
+ },
430
+ {
431
+ id: 'high',
432
+ name: 'High',
433
+ },
434
+ ];
435
+ function abortError() {
436
+ return new LlmError('AGY request aborted by caller', 'ABORTED');
437
+ }
438
+ /**
439
+ * Fingerprint of the DSH history one conversation holds: a short hash over the
440
+ * serialized system + messages. Kept deliberately independent of the outbound
441
+ * prompt (image bridges and tool contracts may decorate it) — this hash only
442
+ * answers "is the DSH history the same one the AGY conversation was built on".
443
+ */
444
+ function historyFingerprintOf(messages, system) {
445
+ // serializeAgyPrompt reads only, but its parameter keeps the host's mutable
446
+ // array type — the shallow copy is for the type, the cost is one array.
447
+ const serialized = serializeAgyPrompt({
448
+ ...(messages.length === 0
449
+ ? { messages: [{ role: 'user', content: [{ type: 'text', text: ' ' }] }] }
450
+ : { messages: [...messages] }),
451
+ ...(system === undefined ? {} : { system }),
452
+ });
453
+ return createHash('sha256').update(serialized, 'utf8').digest('hex').slice(0, 16);
454
+ }
455
+ /** Fingerprint metadata to store after a turn was delivered successfully. */
456
+ function fingerprintAfterTurn(options) {
457
+ return {
458
+ chainHash: historyFingerprintOf(options.messages, options.system),
459
+ messageCount: options.messages.length,
460
+ };
461
+ }
462
+ function idleTimeoutMsOf(result) {
463
+ // The process layer owns the budget; the adapter only knows the elapsed time
464
+ // between the last delivered line and the kill, which is what the message
465
+ // reports so the operator sees the threshold that actually fired.
466
+ const lastLineAt = numberValue(result.lastOutputAtMs);
467
+ return lastLineAt === undefined ? undefined : Math.max(0, Date.now() - lastLineAt);
468
+ }
469
+ function numberValue(value) {
470
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
471
+ }
472
+ function firstNumber(record, keys) {
473
+ for (const key of keys) {
474
+ const value = numberValue(record[key]);
475
+ if (value !== undefined)
476
+ return value;
477
+ }
478
+ return undefined;
479
+ }
480
+ /** Convert AGY's observed snake/camel-case usage variants to DSH accounting. */
481
+ export function mapAgyUsage(raw) {
482
+ const input = firstNumber(raw, ['inputTokens', 'input_tokens', 'promptTokens', 'prompt_tokens']);
483
+ const output = firstNumber(raw, ['outputTokens', 'output_tokens', 'completionTokens', 'completion_tokens']);
484
+ const cacheRead = firstNumber(raw, ['cacheReadTokens', 'cache_read_tokens', 'cacheReadInputTokens']);
485
+ const cacheWrite = firstNumber(raw, ['cacheWriteTokens', 'cache_write_tokens', 'cacheCreationInputTokens']);
486
+ const reasoning = firstNumber(raw, ['reasoningTokens', 'reasoning_tokens']);
487
+ const total = firstNumber(raw, ['totalTokens', 'total_tokens']);
488
+ // AGY 1.1.13 exposes totalTokens in the observed result. Preserve that
489
+ // total conservatively as input pressure until AGY exposes a split.
490
+ const inputTokens = input ?? (output === undefined ? total ?? 0 : 0);
491
+ const outputTokens = output ?? 0;
492
+ return {
493
+ inputTokens,
494
+ outputTokens,
495
+ ...(cacheRead === undefined ? {} : { cacheReadTokens: cacheRead }),
496
+ ...(cacheWrite === undefined ? {} : { cacheWriteTokens: cacheWrite }),
497
+ ...(reasoning === undefined ? {} : { reasoningTokens: reasoning }),
498
+ };
499
+ }
500
+ function recordUsageTelemetry(telemetry, usage, source, cumulativeRaw) {
501
+ telemetry.usageSource = source;
502
+ telemetry.usage = addUsage(telemetry.usage, usage);
503
+ if (cumulativeRaw !== undefined)
504
+ telemetry.cumulativeUsage = mapAgyUsage(cumulativeRaw);
505
+ const cacheRead = usage.cacheReadTokens;
506
+ if (cacheRead !== undefined) {
507
+ telemetry.cacheHit = cacheRead > 0;
508
+ const denominator = usage.inputTokens + cacheRead;
509
+ telemetry.cacheTokenShare = denominator > 0 ? cacheRead / denominator : 0;
510
+ }
511
+ }
512
+ function asLlmError(error) {
513
+ if (error instanceof LlmError)
514
+ return error;
515
+ if (error instanceof DshContextError) {
516
+ return new LlmError(error.message, error.code, { cause: error });
517
+ }
518
+ if (error instanceof AgyPromptError) {
519
+ return new LlmError(error.message, error.code, { cause: error });
520
+ }
521
+ if (error instanceof AgyPromptBudgetError) {
522
+ return new LlmError(error.message, error.code, { cause: error });
523
+ }
524
+ if (error instanceof ToolProtocolError) {
525
+ return new LlmError(error.message, error.code, { cause: error });
526
+ }
527
+ if (error instanceof AgyImageBridgeError) {
528
+ return new LlmError(error.message, error.code, { cause: error });
529
+ }
530
+ if (error instanceof AgyParserError) {
531
+ return new LlmError(`AGY stream-json parse failed at line ${error.lineNumber}`, 'AGY_PARSE', { cause: error });
532
+ }
533
+ if (error instanceof AgyQueueError) {
534
+ return new LlmError(error.message, error.code, { cause: error });
535
+ }
536
+ if (error instanceof PersistentTransportError) {
537
+ if (error.code === 'TIMEOUT') {
538
+ return new LlmError(error.message, 'TIMEOUT', { cause: error });
539
+ }
540
+ if (error.code === 'WORKER_ERROR') {
541
+ // AGY's own failure text, carried in the result frame. The one-shot path
542
+ // classifies the same text (see classifyAgyFailure on AGY_STATUS) so a
543
+ // proxy-cut stream becomes TRANSPORT and the host retries it; without
544
+ // that step the identical flake surfaces as an unknown code the host
545
+ // refuses to retry. Classification keeps the loud failures loud: an AUTH,
546
+ // quota or context-window error inside this frame still maps to its own
547
+ // non-retryable code instead of being retried five times.
548
+ return new LlmError(error.message, classifyAgyFailure(error.message, 'AGY_WORKER_ERROR'), { cause: error });
549
+ }
550
+ return new LlmError(error.message, `AGY_${error.code}`, { cause: error });
551
+ }
552
+ if (error instanceof AgyProcessError) {
553
+ return new LlmError(error.message, error.code === 'INPUT_TOO_LARGE' ? 'AGY_INPUT_TOO_LARGE' : `AGY_${error.code}`, { cause: error });
554
+ }
555
+ return new LlmError('AGY provider request failed', 'AGY_REQUEST', {
556
+ cause: error instanceof Error ? error : new Error(String(error)),
557
+ });
558
+ }
559
+ function processFailure(result) {
560
+ if (result.termination === 'aborted')
561
+ return abortError();
562
+ if (result.termination === 'timeout') {
563
+ return new LlmError('AGY request exceeded the configured wall-clock timeout', 'TIMEOUT');
564
+ }
565
+ if (result.termination === 'idle-timeout') {
566
+ return new LlmError(`AGY produced no output for ${idleTimeoutMsOf(result) ?? 'the configured'} ms`, 'TIMEOUT');
567
+ }
568
+ if (result.termination === 'output-limit') {
569
+ return new LlmError('AGY output exceeded the configured capture limit', 'AGY_OUTPUT_LIMIT');
570
+ }
571
+ if (result.termination !== 'completed' || result.exitCode !== 0) {
572
+ const stderr = result.stderr.trim();
573
+ const code = classifyAgyFailure(stderr, 'AGY_EXIT');
574
+ return new LlmError(stderr.length === 0
575
+ ? `AGY exited unsuccessfully (exit code ${result.exitCode ?? 'unknown'})`
576
+ : safeAgyFailureMessage('AGY exited unsuccessfully', stderr), code);
577
+ }
578
+ return undefined;
579
+ }
580
+ function isSuccessStatus(status) {
581
+ return status === undefined || status.toUpperCase() === 'SUCCESS';
582
+ }
583
+ /**
584
+ * Claude 4.6 occasionally ignores the final JSON-envelope instruction even
585
+ * though its tool-free AGY Agent has no native tools available. Preserve the
586
+ * strict parser for JSON-looking output and every other model, but treat a
587
+ * genuinely plain Claude response as a final DSH message. This fallback can
588
+ * never create a tool call; malformed structured output still fails closed.
589
+ */
590
+ function parseAgyToolEnvelope(raw, protocol, model, allowPlainTextFallback, options = {}) {
591
+ try {
592
+ return parseStructuredEnvelope(raw, protocol, options);
593
+ }
594
+ catch (error) {
595
+ if (!(error instanceof ToolProtocolError)
596
+ || error.code !== TOOL_PROTOCOL_RESPONSE_INVALID_CODE
597
+ || (!allowPlainTextFallback && !/^claude-/i.test(model))
598
+ || typeof raw !== 'string')
599
+ throw error;
600
+ const content = raw.trim();
601
+ if (content.length === 0 || /^[{[]/.test(content) || /^```/i.test(content))
602
+ throw error;
603
+ return parseStructuredEnvelope({ kind: 'message', content }, protocol, options);
604
+ }
605
+ }
606
+ function toolProtocolErrorOf(error) {
607
+ let current = error;
608
+ for (let depth = 0; depth < 8 && current !== undefined; depth += 1) {
609
+ if (current instanceof ToolProtocolError)
610
+ return current;
611
+ if (!(current instanceof Error))
612
+ return undefined;
613
+ current = current.cause;
614
+ }
615
+ return undefined;
616
+ }
617
+ function isRepairableToolProtocolError(error) {
618
+ const protocolError = toolProtocolErrorOf(error);
619
+ return protocolError !== undefined
620
+ && ((protocolError.code === TOOL_PROTOCOL_RESPONSE_INVALID_CODE && protocolError.detail === 'not JSON')
621
+ || protocolError.code === TOOL_PROTOCOL_ARGUMENTS_INVALID_CODE);
622
+ }
623
+ function errorCodeOf(error) {
624
+ let current = error;
625
+ for (let depth = 0; depth < 8 && current !== undefined; depth += 1) {
626
+ if (current instanceof Error) {
627
+ const code = current.code;
628
+ if (typeof code === 'string')
629
+ return code;
630
+ current = current.cause;
631
+ continue;
632
+ }
633
+ return undefined;
634
+ }
635
+ return undefined;
636
+ }
637
+ function isRepairableAgyRequestError(error) {
638
+ return isRepairableToolProtocolError(error) || errorCodeOf(error) === AGY_INTERNAL_TOOL_EVENT_CODE;
639
+ }
640
+ function protocolRepairReasonOf(error) {
641
+ return errorCodeOf(error) === AGY_INTERNAL_TOOL_EVENT_CODE ? 'internal-tool-event' : undefined;
642
+ }
643
+ function protocolRepairHintOf(error) {
644
+ const diagnostic = toolProtocolErrorOf(error)?.diagnostic;
645
+ if (diagnostic === undefined)
646
+ return undefined;
647
+ return {
648
+ toolName: diagnostic.toolName,
649
+ issue: diagnostic.issue,
650
+ ...(diagnostic.missingRequiredKeys === undefined
651
+ ? {}
652
+ : { missingRequiredKeys: diagnostic.missingRequiredKeys }),
653
+ };
654
+ }
655
+ function protocolFailureDetailOf(error) {
656
+ if (error.detail === 'not JSON')
657
+ return 'not-json';
658
+ if (error.detail === 'envelope')
659
+ return 'envelope';
660
+ if (error.detail === 'message shape')
661
+ return 'message-shape';
662
+ if (error.detail === 'tool_call shape')
663
+ return 'tool-call-shape';
664
+ if (error.code === TOOL_PROTOCOL_RESPONSE_LIMIT_CODE)
665
+ return 'response-limit';
666
+ if (error.code.startsWith('TOOL_PROTOCOL_'))
667
+ return 'validation';
668
+ return 'unknown';
669
+ }
670
+ function protocolResponseShapeOf(raw) {
671
+ if (typeof raw !== 'string')
672
+ return 'non-string';
673
+ const trimmed = raw.trim();
674
+ if (trimmed.length === 0)
675
+ return 'empty';
676
+ if (/^```/i.test(trimmed))
677
+ return 'fenced';
678
+ if (trimmed.startsWith('{'))
679
+ return 'object-like';
680
+ if (trimmed.startsWith('['))
681
+ return 'array-like';
682
+ return 'plain-text';
683
+ }
684
+ function recordProtocolFailure(telemetry, raw, error) {
685
+ const protocolError = toolProtocolErrorOf(error);
686
+ if (protocolError === undefined)
687
+ return;
688
+ telemetry.protocolFailureDetail = protocolFailureDetailOf(protocolError);
689
+ telemetry.protocolResponseShape = protocolResponseShapeOf(raw);
690
+ telemetry.protocolResponseBytes = typeof raw === 'string'
691
+ ? Buffer.byteLength(raw, 'utf8')
692
+ : undefined;
693
+ if (protocolError.code === TOOL_PROTOCOL_RESPONSE_INVALID_CODE && protocolError.detail === 'not JSON') {
694
+ telemetry.protocolRepairReason ??= 'not-json';
695
+ }
696
+ else if (protocolError.code === TOOL_PROTOCOL_ARGUMENTS_INVALID_CODE) {
697
+ telemetry.protocolRepairReason ??= 'arguments-invalid';
698
+ }
699
+ const diagnostic = protocolError.diagnostic;
700
+ if (diagnostic !== undefined) {
701
+ telemetry.protocolToolName = diagnostic.toolName;
702
+ telemetry.protocolArgumentIssue = diagnostic.issue;
703
+ telemetry.protocolMissingRequiredKeys = diagnostic.missingRequiredKeys;
704
+ telemetry.protocolReceivedArgumentKeys = diagnostic.receivedArgumentKeys;
705
+ }
706
+ }
707
+ function bridgeOutcomeForError(code, current) {
708
+ if (code.startsWith('DSH_'))
709
+ return 'context-rejected';
710
+ if (code === PERMISSION_REQUIRED_CODE)
711
+ return 'permission-required';
712
+ if (code === AGY_INTERNAL_TOOL_EVENT_CODE)
713
+ return 'agy-internal-tool';
714
+ if (code === 'TOOL_PROTOCOL_SCHEMA_INVALID' || code === 'TOOL_PROTOCOL_SCHEMA_LIMIT') {
715
+ return 'schema-rejected';
716
+ }
717
+ if (code.startsWith('TOOL_PROTOCOL_'))
718
+ return 'protocol-rejected';
719
+ if (code === UNSUPPORTED_TOOLS_CODE) {
720
+ if (current === 'agy-internal-tool' || current === 'schema-rejected')
721
+ return current;
722
+ return 'failed';
723
+ }
724
+ return current === 'dsh-pending' ? 'failed' : current;
725
+ }
726
+ function setDshTelemetrySnapshot(telemetry, snapshot) {
727
+ if (snapshot.permissionPreset === 'read-only'
728
+ || snapshot.permissionPreset === 'workspace-write'
729
+ || snapshot.permissionPreset === 'danger-full-access') {
730
+ telemetry.permissionPreset = snapshot.permissionPreset;
731
+ }
732
+ if (snapshot.sandboxMode !== undefined)
733
+ telemetry.sandboxMode = snapshot.sandboxMode;
734
+ if (snapshot.approvalPolicy !== undefined)
735
+ telemetry.approvalPolicy = snapshot.approvalPolicy;
736
+ }
737
+ function normalizeReasoningEffort(value) {
738
+ if (value === undefined)
739
+ return undefined;
740
+ if (isAgyReasoningEffort(value))
741
+ return value;
742
+ throw new LlmError('AGY supports reasoning efforts: low, medium, high', UNSUPPORTED_REASONING_EFFORT_CODE);
743
+ }
744
+ // The panel's "default thinking strength" control writes ~/.dsh/agy-cli/
745
+ // runtime-overrides.json; this reads it back so the generation path uses the
746
+ // user's choice as the last-resort effort instead of the built-in 'high'.
747
+ // 30s TTL so a panel change takes effect without a host restart; an invalid or
748
+ // missing file silently falls back to the built-in default.
749
+ // `__effortOverrideCacheForTest` is the mutable record the test harness can
750
+ // poke to clear the cache between cases without a process restart.
751
+ export const __effortOverrideCacheForTest = { at: 0, value: undefined };
752
+ const EFFORT_OVERRIDE_TTL_MS = 30_000;
753
+ function readDefaultEffortOverride() {
754
+ const now = Date.now();
755
+ if (now - __effortOverrideCacheForTest.at < EFFORT_OVERRIDE_TTL_MS)
756
+ return __effortOverrideCacheForTest.value;
757
+ __effortOverrideCacheForTest.at = now;
758
+ __effortOverrideCacheForTest.value = readAgyRuntimeOverrides()?.defaultEffort;
759
+ return __effortOverrideCacheForTest.value;
760
+ }
761
+ function resolveAgyRetryPolicy(config) {
762
+ const maxRetries = config?.maxRetries ?? 5;
763
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
764
+ throw new RangeError('AGY retryPolicy.maxRetries must be an integer between 0 and 5');
765
+ }
766
+ const retryableCodes = config?.retryableCodes ?? [...AGY_RETRYABLE_CODES];
767
+ // A configured list is normalized against the allowlist rather than rejected:
768
+ // a profile written before AGY_INPUT_FAILED/AGY_SPAWN_FAILED joined the list
769
+ // would otherwise make this resolver throw on every call, and the failure mode
770
+ // of a stale config must never be "the provider refuses to start". Codes that
771
+ // are no longer retryable are dropped, except when that would leave nothing to
772
+ // retry — a non-empty list is a schema requirement, so falling back to the full
773
+ // allowlist is safer than silently disabling retries.
774
+ const allowed = new Set(AGY_RETRYABLE_CODES);
775
+ const dropped = [...new Set(retryableCodes.filter(code => !allowed.has(code)))];
776
+ if (dropped.length > 0) {
777
+ process.emitWarning(`AGY retryPolicy.retryableCodes dropped codes that are not retryable: ${dropped.join(', ')}`, { code: 'DSH_AGY_RETRY_CODES' });
778
+ }
779
+ const filtered = retryableCodes.filter((code) => allowed.has(code));
780
+ const effectiveCodes = filtered.length > 0 ? filtered : [...AGY_RETRYABLE_CODES];
781
+ return resolveRetryPolicy({
782
+ mode: 'normal',
783
+ maxRetries,
784
+ retryableCodes: [...effectiveCodes],
785
+ }, 'dsh-agy-cli.retryPolicy');
786
+ }
787
+ function resolveBoundedInteger(name, value, fallback, minimum, maximum) {
788
+ const resolved = value ?? fallback;
789
+ if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) {
790
+ throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
791
+ }
792
+ return resolved;
793
+ }
794
+ function addUsage(left, right) {
795
+ const sumOptional = (key) => {
796
+ const leftValue = left?.[key];
797
+ const rightValue = right[key];
798
+ if (leftValue === undefined && rightValue === undefined)
799
+ return undefined;
800
+ return (leftValue ?? 0) + (rightValue ?? 0);
801
+ };
802
+ const cacheReadTokens = sumOptional('cacheReadTokens');
803
+ const cacheWriteTokens = sumOptional('cacheWriteTokens');
804
+ const reasoningTokens = sumOptional('reasoningTokens');
805
+ return {
806
+ inputTokens: (left?.inputTokens ?? 0) + right.inputTokens,
807
+ outputTokens: (left?.outputTokens ?? 0) + right.outputTokens,
808
+ ...(cacheReadTokens === undefined
809
+ ? {}
810
+ : { cacheReadTokens }),
811
+ ...(cacheWriteTokens === undefined
812
+ ? {}
813
+ : { cacheWriteTokens }),
814
+ ...(reasoningTokens === undefined
815
+ ? {}
816
+ : { reasoningTokens }),
817
+ };
818
+ }
819
+ function purposeRouteFor(purpose, routes) {
820
+ if (purpose === 'compaction')
821
+ return routes?.compaction;
822
+ if (purpose === 'session-title')
823
+ return routes?.sessionTitle;
824
+ return undefined;
825
+ }
826
+ /**
827
+ * A request with no tool catalog is never a user turn: DSH ships its tool list
828
+ * whenever the model is expected to act, and the MCP bridge still reports the
829
+ * schemas in `toolSchemaCount` even though they leave the prompt. Such calls are
830
+ * side channels, and their payload is a serialized dump of the conversation as
831
+ * ONE message — the session-title provider (`@weibaohui/dsh-smart-title`,
832
+ * `purpose: 'session-title'`), compaction, and `@deepseek-ai/dsh-experimental-auto-review`
833
+ * (whose review request has no `purpose` at all and whose text is
834
+ * ENVIRONMENT / PROJECT_INSTRUCTIONS / FILTERED_HISTORY / PENDING_ACTION joined).
835
+ * A dump is the only place where a single section can exceed the prompt budget,
836
+ * and clipping its head drops just the oldest context. A real turn keeps the
837
+ * strict path: losing the start of the user's own message silently is worse than
838
+ * a clean AGY_INPUT_TOO_LARGE.
839
+ */
840
+ /**
841
+ * Per-request argv tail for a persistent worker. `--disable-slash-commands` is
842
+ * fixed in the transport args (it never varies), while `--mode` travels with
843
+ * the route so a worker spawned for a preset keeps its bounded mode.
844
+ */
845
+ function persistentExtraArgsOf(mode) {
846
+ return mode === undefined ? [] : ['--mode', mode];
847
+ }
848
+ function isSideChannelRequest(toolProtocol, toolSchemaCount) {
849
+ return toolProtocol === undefined && toolSchemaCount === 0;
850
+ }
851
+ /** DSH text-only adapter backed by the locally authenticated AGY CLI. */
852
+ export class AgyAdapter extends LlmAdapter {
853
+ model;
854
+ models;
855
+ agent;
856
+ workspaceRoot;
857
+ addDirs;
858
+ mode;
859
+ disableSlashCommands;
860
+ toolPolicy;
861
+ dshContext;
862
+ agyPath;
863
+ timeoutMs;
864
+ idleTimeoutMs;
865
+ stdinMode;
866
+ run;
867
+ sessionMode;
868
+ sessions;
869
+ minimumAgyVersion;
870
+ limiter;
871
+ logger;
872
+ maxOutputBytes;
873
+ maxEventLineLength;
874
+ inputFrameLimitBytes;
875
+ maxSingleToolResultBytes;
876
+ maxHistoricalToolResultBytes;
877
+ toolProtocolRepairRetries;
878
+ toolProtocolPlainTextFallback;
879
+ retryPolicy;
880
+ purposeRoutes;
881
+ transport;
882
+ persistentIdleTtlMs;
883
+ persistentReadyTimeoutMs;
884
+ persistentFallback;
885
+ persistentTransport;
886
+ imageInput;
887
+ imageAgent;
888
+ resolveAttachmentStore;
889
+ resolveToolBridge;
890
+ mcpBridgeEnabled;
891
+ discovery;
892
+ visibleModels;
893
+ currentModels;
894
+ modelDiscoveryResult;
895
+ constructor(config = {}, dependencies = {}) {
896
+ super();
897
+ // Normalize default model to base (strip -high/-medium/-low) for effort split
898
+ this.model = normalizeModelId(config.model ?? DEFAULT_MODEL);
899
+ this.models = configuredModels(config).map(m => ({ ...m, id: normalizeModelId(m.id) }));
900
+ this.visibleModels = (config.visibleModels ?? []).map(normalizeModelId);
901
+ this.toolPolicy = config.toolPolicy === 'agy-owned'
902
+ ? 'agy-owned'
903
+ : config.toolPolicy === 'dsh-owned' ? 'dsh-owned' : 'reject';
904
+ const agentRuntime = resolveAgyAgentRuntime(config, this.toolPolicy);
905
+ this.agent = agentRuntime.agent;
906
+ this.workspaceRoot = agentRuntime.workspaceRoot;
907
+ this.addDirs = agentRuntime.addDirs;
908
+ this.mode = agentRuntime.mode;
909
+ this.disableSlashCommands = agentRuntime.disableSlashCommands;
910
+ this.dshContext = dependencies.dshContext;
911
+ this.resolveToolBridge = dependencies.resolveToolBridge;
912
+ this.mcpBridgeEnabled = config.mcpBridge !== false;
913
+ this.agyPath = config.agyPath?.trim() === '' ? undefined : config.agyPath?.trim();
914
+ this.timeoutMs = config.timeoutMs ?? 900_000;
915
+ this.idleTimeoutMs = config.idleTimeoutMs ?? 300_000;
916
+ this.stdinMode = config.stdinMode ?? 'hold';
917
+ this.run = dependencies.runAgyProcess ?? runAgyProcess;
918
+ this.sessionMode = config.sessionMode === 'resume' ? 'resume' : 'full';
919
+ this.sessions = dependencies.sessionRegistry ?? new SessionRegistry();
920
+ this.minimumAgyVersion = config.minimumAgyVersion?.trim() || DEFAULT_MINIMUM_AGY_VERSION;
921
+ this.limiter = dependencies.concurrencyLimiter ?? new AgyConcurrencyLimiter({
922
+ maxConcurrent: config.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
923
+ maxQueue: config.maxQueue ?? DEFAULT_MAX_QUEUE,
924
+ queueTimeoutMs: config.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
925
+ });
926
+ this.logger = dependencies.logger;
927
+ this.maxOutputBytes = config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
928
+ this.maxEventLineLength = config.maxEventLineLength ?? DEFAULT_MAX_EVENT_LINE_LENGTH;
929
+ this.inputFrameLimitBytes = resolveBoundedInteger('inputFrameLimitBytes', config.inputFrameLimitBytes, DEFAULT_INPUT_FRAME_LIMIT_BYTES, 128, 16 * 1024 * 1024);
930
+ this.maxSingleToolResultBytes = resolveBoundedInteger('maxSingleToolResultBytes', config.maxSingleToolResultBytes, DEFAULT_MAX_SINGLE_TOOL_RESULT_BYTES, 1_024, 512 * 1024);
931
+ this.maxHistoricalToolResultBytes = resolveBoundedInteger('maxHistoricalToolResultBytes', config.maxHistoricalToolResultBytes, DEFAULT_MAX_HISTORICAL_TOOL_RESULT_BYTES, 1_024, 2 * 1024 * 1024);
932
+ this.toolProtocolRepairRetries = resolveBoundedInteger('toolProtocolRepairRetries', config.toolProtocolRepairRetries, 1, 0, 1);
933
+ const plainTextFallback = config.toolProtocolPlainTextFallback ?? 'final-message';
934
+ if (plainTextFallback !== 'off' && plainTextFallback !== 'final-message') {
935
+ throw new RangeError('toolProtocolPlainTextFallback must be off or final-message');
936
+ }
937
+ this.toolProtocolPlainTextFallback = plainTextFallback;
938
+ this.retryPolicy = resolveAgyRetryPolicy(config.retryPolicy);
939
+ this.transport = config.transport === 'persistent' ? 'persistent' : 'one-shot';
940
+ this.persistentIdleTtlMs = config.persistentIdleTtlMs ?? 30_000;
941
+ this.persistentReadyTimeoutMs = config.persistentReadyTimeoutMs ?? 10_000;
942
+ this.persistentFallback = config.persistentFallback === 'never' ? 'never' : 'before-accept';
943
+ this.purposeRoutes = config.purposeRoutes;
944
+ this.imageInput = config.imageInput === 'experimental' ? 'experimental' : 'off';
945
+ const imagePreset = getAgentPreset('image-view');
946
+ this.imageAgent = this.imageInput !== 'experimental' || imagePreset === undefined
947
+ ? undefined
948
+ : {
949
+ agent: imagePreset.agentName,
950
+ agentPreset: imagePreset.id,
951
+ agentCanViewFile: true,
952
+ workspaceRoot: undefined,
953
+ addDirs: undefined,
954
+ mode: imagePreset.mode,
955
+ disableSlashCommands: true,
956
+ };
957
+ this.resolveAttachmentStore = dependencies.resolveAttachmentStore
958
+ ?? (() => dependencies.attachmentStore);
959
+ this.currentModels = this.models;
960
+ this.discovery = config.modelDiscovery === 'off'
961
+ ? undefined
962
+ : new AgyModelDiscovery({
963
+ ...(this.agyPath === undefined ? {} : { executable: this.agyPath }),
964
+ ...(config.modelDiscoveryTtlMs === undefined ? {} : { ttlMs: config.modelDiscoveryTtlMs }),
965
+ ...(config.modelDiscoveryTimeoutMs === undefined ? {} : { timeoutMs: config.modelDiscoveryTimeoutMs }),
966
+ maxOutputBytes: this.maxOutputBytes,
967
+ ...(dependencies.runModelDiscovery === undefined ? {} : { runCommand: dependencies.runModelDiscovery }),
968
+ });
969
+ // Persistent transport is opt-in; one-shot remains default. It is created only when transport:persistent.
970
+ // Route args (--agent/--model/--effort/--conversation) are NOT fixed here:
971
+ // AGY reads them at process start, and the route varies per request (the
972
+ // user can switch model inside one session). Each request supplies its own
973
+ // route and the pool keys workers by it. See PersistentTransportRequest.
974
+ this.persistentTransport = this.transport !== 'persistent' ? undefined : (dependencies.persistentTransport ?? new ExperimentalAgyTransport({
975
+ executable: resolveAgyExecutable(this.agyPath),
976
+ args: [
977
+ '-p', '',
978
+ '--input-format', 'stream-json',
979
+ '--output-format', 'stream-json',
980
+ // workspaceRoot is cwd for persistent worker, not an arg
981
+ ...(this.disableSlashCommands ? ['--disable-slash-commands'] : []),
982
+ ],
983
+ ...(this.workspaceRoot === undefined ? {} : { cwd: this.workspaceRoot }),
984
+ idleTtlMs: this.persistentIdleTtlMs,
985
+ readyTimeoutMs: this.persistentReadyTimeoutMs,
986
+ maxFrameBytes: this.inputFrameLimitBytes,
987
+ maxWorkers: config.maxConcurrent ?? 4,
988
+ }));
989
+ }
990
+ preparePrompt(basePrompt, toolProtocol, protocolRepair, protocolRepairHint, protocolRepairReason, telemetry) {
991
+ let prompt = toolProtocol === undefined
992
+ ? basePrompt
993
+ : appendToolProtocolPrompt(basePrompt, toolProtocol);
994
+ if (toolProtocol !== undefined)
995
+ telemetry.toolSchemaHash = toolProtocol.schemaHash;
996
+ if (toolProtocol !== undefined && protocolRepair) {
997
+ prompt = appendToolProtocolRepairPrompt(prompt, protocolRepairHint, protocolRepairReason);
998
+ }
999
+ // Side channels (session title, compaction, auto-review) are the only
1000
+ // requests with no tool catalog, and their payload is one huge conversation
1001
+ // dump. Clipping the head of that dump beats AGY_INPUT_TOO_LARGE, which
1002
+ // leaves the title stale and the tool call unreviewed. See the helper.
1003
+ const oversizedSectionPolicy = isSideChannelRequest(toolProtocol, telemetry.toolSchemaCount) ? 'clamp' : 'reject';
1004
+ const bounded = boundAgyPrompt(prompt, {
1005
+ maxFrameBytes: this.inputFrameLimitBytes,
1006
+ maxPromptBytes: toolProtocol === undefined
1007
+ ? DEFAULT_AGY_PROMPT_CONTENT_LIMIT_BYTES
1008
+ : DEFAULT_DSH_TOOL_PROMPT_CONTENT_LIMIT_BYTES,
1009
+ maxSingleToolResultBytes: this.maxSingleToolResultBytes,
1010
+ maxHistoricalToolResultBytes: this.maxHistoricalToolResultBytes,
1011
+ oversizedSectionPolicy,
1012
+ });
1013
+ telemetry.promptBytes = bounded.promptBytes;
1014
+ telemetry.promptLimitBytes = bounded.promptLimitBytes;
1015
+ telemetry.inputFrameBytes = bounded.frameBytes;
1016
+ telemetry.inputFrameLimitBytes = bounded.frameLimitBytes;
1017
+ telemetry.toolResultCount = bounded.toolResultCount;
1018
+ telemetry.largestToolResultBytes = bounded.largestToolResultBytes;
1019
+ telemetry.truncatedToolResultCount = bounded.truncatedToolResultCount;
1020
+ telemetry.historyCompacted = bounded.historyCompacted;
1021
+ telemetry.omittedMessageCount = bounded.omittedMessageCount;
1022
+ const stablePrefix = stableAgyPromptPrefix(bounded.prompt);
1023
+ telemetry.stablePrefixBytes = Buffer.byteLength(stablePrefix, 'utf8');
1024
+ telemetry.stablePrefixHash = createHash('sha256').update(stablePrefix, 'utf8').digest('hex').slice(0, 16);
1025
+ return bounded.prompt;
1026
+ }
1027
+ /** Remove the in-memory AGY mapping; the next call sends complete DSH history. */
1028
+ clearSession(sessionId) {
1029
+ return this.sessions.delete(sessionId);
1030
+ }
1031
+ /** Inspect the detached mapping for diagnostics and tests. */
1032
+ getSession(sessionId) {
1033
+ return this.sessions.get(sessionId);
1034
+ }
1035
+ /** Return process-slot state without exposing prompts, paths, or credentials. */
1036
+ getConcurrencyStats() {
1037
+ return this.limiter.getStats();
1038
+ }
1039
+ /** Check AGY path, version, and configured Agent without spending model quota. */
1040
+ diagnose() {
1041
+ return diagnoseAgy({
1042
+ ...(this.agyPath === undefined ? {} : { executable: this.agyPath }),
1043
+ expectedAgent: this.agent,
1044
+ minimumVersion: this.minimumAgyVersion,
1045
+ timeoutMs: Math.min(this.timeoutMs, 10_000),
1046
+ });
1047
+ }
1048
+ providerInfo(provider) {
1049
+ return { id: provider, name: 'Antigravity CLI' };
1050
+ }
1051
+ providerRetryPolicy(_provider) {
1052
+ return this.retryPolicy;
1053
+ }
1054
+ async listModels(provider) {
1055
+ const models = await this.effectiveModels();
1056
+ const inputModalities = this.imageInput === 'experimental'
1057
+ ? ['text', 'image']
1058
+ : ['text'];
1059
+ return models.map(model => ({
1060
+ provider,
1061
+ id: model.id,
1062
+ name: model.name ?? model.id,
1063
+ inputModalities,
1064
+ }));
1065
+ }
1066
+ /** Expose safe discovery state for diagnostics and integration tests. */
1067
+ getModelDiscoveryStatus() {
1068
+ return this.modelDiscoveryResult ?? {
1069
+ models: this.currentModels,
1070
+ source: 'static',
1071
+ stale: false,
1072
+ };
1073
+ }
1074
+ resolveModel(provider, model, _signal) {
1075
+ const baseId = normalizeModelId(model);
1076
+ const suffixEffort = extractModelEffort(model);
1077
+ // Log deprecation if suffix used (telemetry handled by caller via model id)
1078
+ const configured = this.currentModels.find(entry => entry.id.toLowerCase() === baseId.toLowerCase()) ?? this.currentModels.find(entry => entry.id === model);
1079
+ void suffixEffort; // suffix implies effort, handled in stream route; resolve keeps base id for UI consistency
1080
+ // "Default thinking strength" is a user setting: the panel control writes it to
1081
+ // ~/.dsh/agy-cli/runtime-overrides.json (single source of truth), and the
1082
+ // generation path reads it here. It is only a last-resort fallback — a
1083
+ // purpose route, the host's requested effort, or a model-id suffix all win.
1084
+ // Re-read per call with a 30s TTL (the panel can change it without a restart);
1085
+ // absent/invalid file falls back to 'high', the built-in gemini default.
1086
+ const overrideEffort = readDefaultEffortOverride();
1087
+ const defaultEffort = baseId.toLowerCase().startsWith('gemini-')
1088
+ ? (overrideEffort ?? 'high')
1089
+ : undefined;
1090
+ const inputModalities = this.imageInput === 'experimental'
1091
+ ? ['text', 'image']
1092
+ : ['text'];
1093
+ return Promise.resolve({
1094
+ provider,
1095
+ id: baseId,
1096
+ name: configured?.name ?? baseId,
1097
+ inputModalities,
1098
+ ...(configured?.contextWindow === undefined
1099
+ ? {}
1100
+ : { context: { contextWindow: configured.contextWindow } }),
1101
+ reasoning: {
1102
+ efforts: AGY_REASONING_METADATA,
1103
+ ...(defaultEffort === undefined ? {} : { defaultEffort }),
1104
+ },
1105
+ });
1106
+ }
1107
+ shouldUsePersistent(options, sessionKey, dshContextSnapshot, toolSchemaCount, hasImage, mcpToolsEnabled = false) {
1108
+ if (this.transport !== 'persistent' || this.persistentTransport === undefined)
1109
+ return false;
1110
+ if (sessionKey === undefined)
1111
+ return false;
1112
+ if (options.purpose === 'compaction' || options.purpose === 'sessionTitle' || options.purpose === 'session-title')
1113
+ return false;
1114
+ if (hasImage)
1115
+ return false;
1116
+ // The AGY main-agent wrapper can emit send_message before a DSH-owned
1117
+ // tool turn reaches its final result. Keep these turns on one-shot so the
1118
+ // carrier can be captured and the tainted process can be terminated
1119
+ // deterministically; persistent remains available for text-only turns.
1120
+ // That reasoning is specific to the prompt-embedded tool protocol: under
1121
+ // the MCP bridge no carrier exists (the tool catalogue travels out of
1122
+ // band), so there is nothing to capture and nothing to terminate early.
1123
+ if (!mcpToolsEnabled && this.toolPolicy === 'dsh-owned' && toolSchemaCount > 0)
1124
+ return false;
1125
+ if (toolSchemaCount > 0 && dshContextSnapshot?.state !== 'ready')
1126
+ return false;
1127
+ return true;
1128
+ }
1129
+ async *streamPersistentAttempt(options, sessionKey, telemetry, route, prepared, toolProtocol, dshContextSnapshot, protocolRepair, protocolRepairHint, protocolRepairReason, mcpToolsEnabled = false) {
1130
+ if (toolProtocol !== undefined && (dshContextSnapshot?.state !== 'ready'
1131
+ || dshContextSnapshot.sessionState !== 'trusted'
1132
+ || dshContextSnapshot.workspaceState !== 'trusted')) {
1133
+ throw new DshContextError(DSH_CONTEXT_UNAVAILABLE_CODE);
1134
+ }
1135
+ telemetry.processAttemptCount += 1;
1136
+ const mappedConversationId = this.sessions.get(sessionKey)?.conversationId;
1137
+ const hasConversation = mappedConversationId !== undefined;
1138
+ const basePrompt = hasConversation
1139
+ ? (prepared.turnPrompt ?? serializeAgyTurnPrompt(options))
1140
+ : prepared.fullPrompt;
1141
+ const prompt = this.preparePrompt(basePrompt, toolProtocol, protocolRepair, protocolRepairHint, protocolRepairReason, telemetry);
1142
+ // Persistent worker is session-affine, single active turn, reuse AGY conversation
1143
+ telemetry.dispatch = 'persistent';
1144
+ const dispatchStartedAt = Date.now();
1145
+ // Stream-chunk shape: the host renders each delta as it arrives, so the
1146
+ // persistent worker must be consumed with an event callback and an async
1147
+ // queue, exactly like the one-shot path. Waiting for `request()` to resolve
1148
+ // would buffer the whole turn and show nothing until it finished.
1149
+ const queue = new AsyncQueue();
1150
+ let persistentRequest;
1151
+ const requestPromise = this.persistentTransport.request({
1152
+ sessionId: sessionKey,
1153
+ text: prompt,
1154
+ // The worker is seeded with the conversation, so an incremental prompt
1155
+ // can never land in a fresh AGY conversation. A worker that comes up
1156
+ // with a different id rejects the request instead of answering without
1157
+ // context, and the caller resends the full DSH history.
1158
+ ...(hasConversation ? { conversation: mappedConversationId } : {}),
1159
+ agent: route.agent,
1160
+ model: route.model,
1161
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
1162
+ ...(persistentExtraArgsOf(this.mode).length === 0
1163
+ ? {}
1164
+ : { extraArgs: persistentExtraArgsOf(this.mode) }),
1165
+ timeoutMs: this.timeoutMs,
1166
+ idleTimeoutMs: this.idleTimeoutMs,
1167
+ maxOutputBytes: this.maxOutputBytes,
1168
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1169
+ onEvent: payload => {
1170
+ const r = payload;
1171
+ const ev = r.event;
1172
+ if (ev === 'step_update') {
1173
+ const su = r.step_update;
1174
+ queue.push({
1175
+ event: 'step_update',
1176
+ ...(su === undefined ? {} : { step_update: su }),
1177
+ });
1178
+ }
1179
+ else if (ev === 'result') {
1180
+ const res = r.result;
1181
+ queue.push({
1182
+ event: 'result',
1183
+ ...(res === undefined ? {} : { result: res }),
1184
+ });
1185
+ }
1186
+ },
1187
+ });
1188
+ persistentRequest = requestPromise.then(result => {
1189
+ telemetry.firstEventMs = result.firstEventMs;
1190
+ queue.end();
1191
+ return result;
1192
+ }, error => {
1193
+ queue.fail(error);
1194
+ throw error;
1195
+ });
1196
+ let blockStarted = false;
1197
+ let finalResponse;
1198
+ let finalStatus;
1199
+ let finalErrorDetail;
1200
+ // AGY's own reason when the turn failed. Kept apart from `finalErrorDetail`
1201
+ // because a failed turn can also carry a placeholder under `response`
1202
+ // ("(no content)"), and delivering a placeholder as the answer is worse
1203
+ // than delivering the partial text already streamed.
1204
+ let lastResultError;
1205
+ let latestStepUsage;
1206
+ let resultUsage;
1207
+ // One consolidated reasoning block records the bridged tool steps, so the
1208
+ // transcript shows what AGY did without the host replaying any of it.
1209
+ let mcpStepsOpen = false;
1210
+ let mcpStepsText = '';
1211
+ let mcpStepCount = 0;
1212
+ let mcpBannerEmitted = false;
1213
+ let modelText = '';
1214
+ try {
1215
+ for await (const event of queue) {
1216
+ const r = event;
1217
+ const ev = r.event;
1218
+ if (ev === 'step_update') {
1219
+ const su = r.step_update;
1220
+ const usage = su?.usage;
1221
+ const convId = su?.conversation_id;
1222
+ if (convId !== undefined) {
1223
+ telemetry.conversationId = convId;
1224
+ this.sessions.set(sessionKey, convId);
1225
+ }
1226
+ if (usage !== undefined)
1227
+ latestStepUsage = usage;
1228
+ telemetry.firstEventMs ??= Date.now() - dispatchStartedAt;
1229
+ telemetry.eventCount += 1;
1230
+ telemetry.eventCategoryCounts[eventCategoryOf(event)] += 1;
1231
+ recordToolEvent(telemetry, event);
1232
+ const internalToolError = dshOwnedToolEventError(event, toolProtocol);
1233
+ if (internalToolError !== undefined)
1234
+ throw internalToolError;
1235
+ }
1236
+ else if (ev === 'result') {
1237
+ const res = r.result;
1238
+ const convId = res?.conversation_id;
1239
+ if (convId !== undefined) {
1240
+ telemetry.conversationId = convId;
1241
+ this.sessions.set(sessionKey, convId);
1242
+ }
1243
+ finalResponse = typeof res?.response === 'string' ? res.response : undefined;
1244
+ finalStatus = typeof res?.status === 'string' ? res.status : undefined;
1245
+ finalErrorDetail = typeof res?.error === 'string' ? res.error : undefined;
1246
+ lastResultError = finalErrorDetail;
1247
+ telemetry.finalStatus = finalStatus;
1248
+ telemetry.finalResultRaw ??= finalResultRawOf(event);
1249
+ telemetry.firstEventMs ??= Date.now() - dispatchStartedAt;
1250
+ telemetry.eventCount += 1;
1251
+ telemetry.eventCategoryCounts[eventCategoryOf(event)] += 1;
1252
+ resultUsage = res?.usage;
1253
+ }
1254
+ if (ev === 'step_update' && mcpToolsEnabled) {
1255
+ const step = mcpToolStepOf(event);
1256
+ if (step !== undefined) {
1257
+ const state = stepStateOf(event);
1258
+ if (state === 'DONE' || state === 'ERROR') {
1259
+ telemetry.toolCallCount += 1;
1260
+ telemetry.bridgeOutcome = 'mcp-tools';
1261
+ if (!mcpStepsOpen) {
1262
+ mcpStepsOpen = true;
1263
+ yield { type: 'block-start', index: 1, blockType: 'reasoning' };
1264
+ }
1265
+ if (!mcpBannerEmitted) {
1266
+ mcpBannerEmitted = true;
1267
+ mcpStepsText += reasoningBanner();
1268
+ yield { type: 'reasoning-delta', index: 1, text: reasoningBanner() };
1269
+ }
1270
+ const line = mcpStepProcessLine(step, state);
1271
+ mcpStepsText += line;
1272
+ mcpStepCount += 1;
1273
+ yield { type: 'reasoning-delta', index: 1, text: line };
1274
+ }
1275
+ continue;
1276
+ }
1277
+ }
1278
+ const delta = toolProtocol === undefined ? textDeltaOf(event) : undefined;
1279
+ if (delta !== undefined && delta.length > 0) {
1280
+ telemetry.firstTextMs ??= Date.now() - dispatchStartedAt;
1281
+ if (!blockStarted) {
1282
+ blockStarted = true;
1283
+ yield { type: 'block-start', index: 0, blockType: 'text' };
1284
+ }
1285
+ modelText += delta;
1286
+ yield { type: 'text-delta', index: 0, text: delta };
1287
+ }
1288
+ }
1289
+ await persistentRequest;
1290
+ }
1291
+ finally {
1292
+ queue.end();
1293
+ persistentRequest?.catch(() => undefined);
1294
+ }
1295
+ const mcpCloseLine = mcpStepCount > 0 ? reasoningCloseLine(mcpStepCount) : '';
1296
+ mcpStepsText = `${mcpStepsText}${mcpCloseLine}`;
1297
+ const selectedUsage = latestStepUsage ?? resultUsage;
1298
+ const usageSource = latestStepUsage !== undefined
1299
+ ? 'step'
1300
+ : selectedUsage === undefined ? undefined : 'result-fallback';
1301
+ const attemptUsage = selectedUsage === undefined ? undefined : mapAgyUsage(selectedUsage);
1302
+ if (attemptUsage !== undefined)
1303
+ recordUsageTelemetry(telemetry, attemptUsage, usageSource, resultUsage);
1304
+ if (toolProtocol !== undefined) {
1305
+ if (finalResponse === undefined) {
1306
+ throw new ToolProtocolError(TOOL_PROTOCOL_RESPONSE_INVALID_CODE, 'missing final response');
1307
+ }
1308
+ let envelope;
1309
+ try {
1310
+ envelope = parseAgyToolEnvelope(finalResponse, toolProtocol, route.model, this.toolProtocolPlainTextFallback === 'final-message'
1311
+ && /^claude-/i.test(route.model), {
1312
+ onCompatibilityApplied: compatibility => {
1313
+ telemetry.protocolCompatibilityApplied = compatibility;
1314
+ },
1315
+ });
1316
+ }
1317
+ catch (error) {
1318
+ recordProtocolFailure(telemetry, finalResponse, error);
1319
+ throw error;
1320
+ }
1321
+ if (envelope.kind === 'message') {
1322
+ telemetry.bridgeOutcome = 'dsh-message';
1323
+ // An empty envelope message is a turn with nothing to say: emit no text
1324
+ // block rather than a blank one (a blank assistant message makes the
1325
+ // host refuse `session/follow` for the whole session).
1326
+ if (envelope.content.length > 0) {
1327
+ yield { type: 'block-start', index: 0, blockType: 'text' };
1328
+ yield { type: 'text-delta', index: 0, text: envelope.content };
1329
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: envelope.content } };
1330
+ }
1331
+ if (attemptUsage !== undefined)
1332
+ yield { type: 'usage', usage: attemptUsage };
1333
+ yield { type: 'finish', reason: { kind: 'stop' } };
1334
+ return { retryWithFullPrompt: false };
1335
+ }
1336
+ const callId = ToolCallId(randomUUID());
1337
+ const argumentsJson = JSON.stringify(envelope.arguments);
1338
+ telemetry.toolCallCount += 1;
1339
+ telemetry.bridgeOutcome = 'dsh-tool-call';
1340
+ yield { type: 'block-start', index: 0, blockType: 'tool-call' };
1341
+ yield { type: 'tool-call-delta', index: 0, id: callId, name: envelope.name, argumentsDelta: argumentsJson };
1342
+ yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: envelope.name, arguments: argumentsJson } };
1343
+ if (attemptUsage !== undefined)
1344
+ yield { type: 'usage', usage: attemptUsage };
1345
+ yield { type: 'finish', reason: { kind: 'tool-calls' } };
1346
+ return { retryWithFullPrompt: false };
1347
+ }
1348
+ // Text-only: already yielded deltas, need to close block and finish.
1349
+ // `result.response` is the authoritative full answer; when no delta was
1350
+ // streamed (AGY spent the turn on tool steps and only summarised at the
1351
+ // end) emit it as the answer. Nothing to emit for a tool-only step: its
1352
+ // tool-call block is the whole record, and a blank text block would land in
1353
+ // the transcript as an empty assistant message (see the one-shot path).
1354
+ // A failed turn's `response` is a placeholder ("(no content)"): when real
1355
+ // text already streamed, keep that text and park AGY's reason as a
1356
+ // reasoning annotation; otherwise fail the turn exactly like one-shot —
1357
+ // delivering the placeholder as the answer is worse than either.
1358
+ const streamedPartialAnswer = finalStatus !== undefined
1359
+ && !isSuccessStatus(finalStatus)
1360
+ && modelText.trim().length > 0;
1361
+ if (finalStatus !== undefined && !isSuccessStatus(finalStatus) && !streamedPartialAnswer) {
1362
+ const detail = [finalStatus, finalErrorDetail].filter(value => value !== undefined).join(' ');
1363
+ const code = classifyAgyFailure(detail, 'AGY_STATUS');
1364
+ throw new LlmError(safeAgyFailureMessage(`AGY returned status ${finalStatus}`, detail), code);
1365
+ }
1366
+ if (streamedPartialAnswer)
1367
+ telemetry.partialAnswerDelivered = true;
1368
+ const appendFinalResponse = !streamedPartialAnswer && finalResponse !== undefined && finalResponse.trim().length > 0;
1369
+ if (appendFinalResponse && finalResponse !== undefined) {
1370
+ const suffix = modelText.length === 0
1371
+ ? finalResponse
1372
+ : finalResponse.startsWith(modelText) ? finalResponse.slice(modelText.length) : '';
1373
+ if (suffix.length > 0) {
1374
+ if (!blockStarted) {
1375
+ blockStarted = true;
1376
+ yield { type: 'block-start', index: 0, blockType: 'text' };
1377
+ }
1378
+ modelText += suffix;
1379
+ yield { type: 'text-delta', index: 0, text: suffix };
1380
+ }
1381
+ }
1382
+ if (blockStarted) {
1383
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: modelText } };
1384
+ }
1385
+ if (streamedPartialAnswer) {
1386
+ const reason = (lastResultError ?? finalErrorDetail ?? 'AGY reported status ERROR').trim();
1387
+ const line = `[agy finished with error] ${reason}\n\n`;
1388
+ if (!mcpStepsOpen) {
1389
+ mcpStepsOpen = true;
1390
+ yield { type: 'block-start', index: 1, blockType: 'reasoning' };
1391
+ }
1392
+ if (!mcpBannerEmitted) {
1393
+ mcpBannerEmitted = true;
1394
+ mcpStepsText += reasoningBanner();
1395
+ yield { type: 'reasoning-delta', index: 1, text: reasoningBanner() };
1396
+ }
1397
+ mcpStepsText += line;
1398
+ yield { type: 'reasoning-delta', index: 1, text: line };
1399
+ }
1400
+ if (mcpStepsOpen) {
1401
+ if (mcpCloseLine !== '') {
1402
+ yield { type: 'reasoning-delta', index: 1, text: mcpCloseLine };
1403
+ }
1404
+ yield { type: 'block-end', index: 1, block: { type: 'reasoning', text: mcpStepsText } };
1405
+ }
1406
+ else if (!blockStarted && telemetry.toolCallCount === 0) {
1407
+ // Same invariant as the one-shot path: a text-only turn that produced
1408
+ // nothing is a failure the host can retry, never a silent success.
1409
+ throw new LlmError('AGY completed without a text response', EMPTY_RESPONSE_CODE);
1410
+ }
1411
+ if (attemptUsage !== undefined)
1412
+ yield { type: 'usage', usage: attemptUsage };
1413
+ yield { type: 'finish', reason: { kind: 'stop' } };
1414
+ return { retryWithFullPrompt: false };
1415
+ }
1416
+ async effectiveModels() {
1417
+ if (this.discovery === undefined) {
1418
+ this.currentModels = filterVisibleModels(this.models, this.visibleModels);
1419
+ this.modelDiscoveryResult = undefined;
1420
+ return this.currentModels;
1421
+ }
1422
+ const result = await this.discovery.discover(this.models);
1423
+ // result.models already normalized to base via mergeModelCatalog; apply visible filter
1424
+ const filtered = filterVisibleModels(result.models, this.visibleModels);
1425
+ this.currentModels = filtered;
1426
+ this.modelDiscoveryResult = { ...result, models: filtered };
1427
+ return this.currentModels;
1428
+ }
1429
+ async *stream(options) {
1430
+ if (options.signal?.aborted)
1431
+ throw abortError();
1432
+ const toolSchemaCount = options.tools?.length ?? 0;
1433
+ const sessionKey = options.sessionId === undefined ? undefined : String(options.sessionId);
1434
+ // Choose the tool transport once per request. The MCP bridge carries the
1435
+ // schemas out of band, so the prompt keeps its whole budget for history and
1436
+ // AGY emits real tool events that the DSH transcript can render.
1437
+ const toolBridge = toolSchemaCount > 0 && this.toolPolicy === 'dsh-owned' && this.mcpBridgeEnabled
1438
+ ? this.resolveToolBridge?.()
1439
+ : undefined;
1440
+ const useToolBridge = toolBridge?.active === true;
1441
+ if (useToolBridge && sessionKey !== undefined)
1442
+ toolBridge.setSession(sessionKey);
1443
+ let toolProtocol;
1444
+ let dshContextSnapshot;
1445
+ const purposeRoute = purposeRouteFor(options.purpose, this.purposeRoutes);
1446
+ const requestedReasoningEffort = normalizeReasoningEffort(options.reasoningEffort);
1447
+ const purposeReasoningEffort = normalizeReasoningEffort(purposeRoute?.reasoningEffort);
1448
+ // Normalize model id and extract effort suffix for backward compat (e.g. gemini-3.7-flash-high)
1449
+ const rawModel = purposeRoute?.model ?? (options.model || this.model);
1450
+ const baseModel = normalizeModelId(rawModel);
1451
+ const suffixEffort = extractModelEffort(rawModel);
1452
+ // AGY 1.1.17+ requires --effort for gemini-* flash/pro models (low/high/medium variants), but claude-* does NOT support --effort.
1453
+ // When nothing higher in the chain chose an effort, the last-resort default is the
1454
+ // user's panel setting (runtime overrides file) if present, else the built-in 'high'
1455
+ // for gemini-* to avoid AGY_REQUEST "requires --effort".
1456
+ const defaultEffortForModel = baseModel.toLowerCase().startsWith('gemini-')
1457
+ ? (readDefaultEffortOverride() ?? 'high')
1458
+ : undefined;
1459
+ let effectiveEffort = purposeReasoningEffort ?? requestedReasoningEffort ?? suffixEffort ?? defaultEffortForModel;
1460
+ // Claude models in AGY 1.1.17 report "is not supported for model" when --effort is sent; suppress for claude-*
1461
+ if (baseModel.toLowerCase().startsWith('claude-'))
1462
+ effectiveEffort = undefined;
1463
+ const route = {
1464
+ model: baseModel,
1465
+ agent: purposeRoute?.agent ?? this.agent,
1466
+ reasoningEffort: effectiveEffort,
1467
+ };
1468
+ let existingConversationId = sessionKey === undefined || this.sessionMode === 'full'
1469
+ ? undefined
1470
+ : this.sessions.get(sessionKey)?.conversationId;
1471
+ // History fingerprint check (design borrowed from dsh-agy-safe's chain):
1472
+ // a resumed turn re-serializes the prefix of the DSH history the record
1473
+ // says AGY already holds. If the host edited, compacted or truncated that
1474
+ // prefix, the fingerprint no longer matches and the turn falls back to the
1475
+ // full-history replay instead of landing a delta prompt on stale context.
1476
+ let existingRecord;
1477
+ let fingerprintMismatch = false;
1478
+ if (sessionKey !== undefined && this.sessionMode === 'resume') {
1479
+ existingRecord = this.sessions.get(sessionKey);
1480
+ if (existingRecord?.chainHash !== undefined && existingRecord.messageCount !== undefined) {
1481
+ const expected = historyFingerprintOf(options.messages.slice(0, existingRecord.messageCount), options.system);
1482
+ fingerprintMismatch = expected !== existingRecord.chainHash;
1483
+ if (fingerprintMismatch) {
1484
+ // The AGY conversation was built on a history that no longer exists
1485
+ // (edited, compacted, truncated). It is unusable in both shapes — a
1486
+ // delta lands on stale context and a full replay would duplicate it —
1487
+ // so the binding is dropped and the turn bootstraps a fresh one.
1488
+ this.sessions.delete(sessionKey);
1489
+ existingRecord = undefined;
1490
+ existingConversationId = undefined;
1491
+ }
1492
+ }
1493
+ }
1494
+ const initialRequestMode = this.sessionMode === 'full'
1495
+ ? 'full'
1496
+ : existingConversationId === undefined || fingerprintMismatch
1497
+ ? 'bootstrap'
1498
+ : 'delta';
1499
+ const telemetry = {
1500
+ requestId: randomUUID(),
1501
+ provider: options.provider,
1502
+ model: route.model,
1503
+ agent: route.agent,
1504
+ toolPolicy: this.toolPolicy,
1505
+ toolSchemaCount,
1506
+ toolCallCount: 0,
1507
+ bridgeOutcome: toolSchemaCount === 0
1508
+ ? 'text-only'
1509
+ : this.toolPolicy === 'dsh-owned'
1510
+ ? 'dsh-pending'
1511
+ : this.toolPolicy === 'agy-owned' ? 'agy-owned' : 'schema-rejected',
1512
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
1513
+ ...(options.purpose === undefined ? {} : { purpose: options.purpose }),
1514
+ ...(this.discovery === undefined
1515
+ ? { modelDiscoverySource: 'static' }
1516
+ : this.modelDiscoveryResult === undefined
1517
+ ? {}
1518
+ : {
1519
+ modelDiscoverySource: this.modelDiscoveryResult.source,
1520
+ ...(this.modelDiscoveryResult.warningCode === undefined
1521
+ ? {}
1522
+ : { modelDiscoveryWarningCode: this.modelDiscoveryResult.warningCode }),
1523
+ }),
1524
+ sessionId: sessionKey,
1525
+ requestMode: initialRequestMode,
1526
+ conversationReused: existingConversationId !== undefined,
1527
+ startedAt: Date.now(),
1528
+ attempt: 1,
1529
+ processAttemptCount: 0,
1530
+ retryMaxRetries: this.retryPolicy.mode === 'normal' ? this.retryPolicy.maxRetries : 0,
1531
+ inputFrameBytes: undefined,
1532
+ inputFrameLimitBytes: undefined,
1533
+ promptBytes: undefined,
1534
+ promptLimitBytes: undefined,
1535
+ toolResultCount: undefined,
1536
+ largestToolResultBytes: undefined,
1537
+ truncatedToolResultCount: undefined,
1538
+ historyCompacted: false,
1539
+ omittedMessageCount: 0,
1540
+ toolEventKind: undefined,
1541
+ toolEventStreamIndex: undefined,
1542
+ toolEventDiagnostic: undefined,
1543
+ carrierAbortExpected: false,
1544
+ carrierValidation: undefined,
1545
+ protocolRepairAttempts: 0,
1546
+ protocolRepairReason: undefined,
1547
+ protocolFailureDetail: undefined,
1548
+ protocolResponseShape: undefined,
1549
+ protocolResponseBytes: undefined,
1550
+ protocolToolName: undefined,
1551
+ protocolArgumentIssue: undefined,
1552
+ protocolMissingRequiredKeys: undefined,
1553
+ protocolReceivedArgumentKeys: undefined,
1554
+ protocolCompatibilityApplied: undefined,
1555
+ eventCount: 0,
1556
+ toolEventCount: 0,
1557
+ permissionEventCount: 0,
1558
+ eventCategoryCounts: emptyAgyEventCategoryCounts(),
1559
+ dispatch: undefined,
1560
+ firstEventMs: undefined,
1561
+ firstTextMs: undefined,
1562
+ finalStatus: undefined,
1563
+ finalResultRaw: undefined,
1564
+ conversationId: undefined,
1565
+ queueWaitMs: undefined,
1566
+ process: undefined,
1567
+ usage: undefined,
1568
+ usageSource: undefined,
1569
+ cumulativeUsage: undefined,
1570
+ cacheHit: undefined,
1571
+ cacheTokenShare: undefined,
1572
+ conversationMismatch: undefined,
1573
+ partialAnswerDelivered: undefined,
1574
+ stablePrefixBytes: undefined,
1575
+ stablePrefixHash: undefined,
1576
+ toolSchemaHash: undefined,
1577
+ processDiagnostic: undefined,
1578
+ durationMs: undefined,
1579
+ };
1580
+ emitAgyLog(this.logger, buildAgyLogRecord(telemetry, 'agy.request.started'));
1581
+ let releaseSession;
1582
+ let releaseProcess;
1583
+ let preparedPrompts;
1584
+ try {
1585
+ if (toolSchemaCount > 0 && this.toolPolicy === 'dsh-owned') {
1586
+ dshContextSnapshot = await resolveDshContext(this.dshContext, {
1587
+ ...(sessionKey === undefined ? {} : { sessionId: sessionKey }),
1588
+ toolSchemaCount,
1589
+ });
1590
+ setDshTelemetrySnapshot(telemetry, dshContextSnapshot);
1591
+ // Under the MCP bridge the tool catalogue travels out of band, so the
1592
+ // prompt contract is neither built nor appended; only the fail-closed
1593
+ // capability check above still runs.
1594
+ if (dshContextSnapshot !== undefined && !useToolBridge) {
1595
+ try {
1596
+ toolProtocol = createStructuredToolProtocol(options.tools ?? []);
1597
+ }
1598
+ catch (error) {
1599
+ telemetry.bridgeOutcome = 'schema-rejected';
1600
+ throw error;
1601
+ }
1602
+ }
1603
+ if (useToolBridge)
1604
+ telemetry.bridgeOutcome = 'mcp-tools';
1605
+ }
1606
+ if (toolSchemaCount > 0 && this.toolPolicy === 'reject') {
1607
+ throw new LlmError('AGY text MVP does not accept DSH tool schemas under toolPolicy: reject; set toolPolicy: dsh-owned or agy-owned explicitly', UNSUPPORTED_TOOLS_CODE);
1608
+ }
1609
+ if (options.temperature !== undefined || options.maxTokens !== undefined || options.stop !== undefined) {
1610
+ // 0.9.0 fix: DSH web may send default temperature/maxTokens/stop; AGY does not support them.
1611
+ // Do not block the request; continue with AGY defaults (previously threw UNSUPPORTED_OPTIONS).
1612
+ }
1613
+ const prepared = await prepareAgyPrompts(options, {
1614
+ enabled: this.imageInput === 'experimental',
1615
+ agentCanViewFile: this.imageAgent?.agentCanViewFile === true,
1616
+ attachmentStore: this.resolveAttachmentStore(),
1617
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1618
+ });
1619
+ preparedPrompts = prepared;
1620
+ releaseSession = sessionKey === undefined ? undefined : await this.sessions.acquire(sessionKey);
1621
+ const queueStartedAt = Date.now();
1622
+ releaseProcess = await this.limiter.acquire(options.signal);
1623
+ telemetry.queueWaitMs = Date.now() - queueStartedAt;
1624
+ // V8-M2 persistent dispatch: one Session one worker, before-accept fallback
1625
+ if (this.shouldUsePersistent(options, sessionKey, dshContextSnapshot, toolSchemaCount, prepared.bridgeActive, useToolBridge)) {
1626
+ let persistentRepairAttempts = 0;
1627
+ let persistentProtocolRepair = false;
1628
+ let persistentProtocolRepairHint;
1629
+ let persistentProtocolRepairReason;
1630
+ while (true) {
1631
+ try {
1632
+ telemetry.attempt = persistentRepairAttempts + 1;
1633
+ const outcome = yield* this.streamPersistentAttempt(options, sessionKey, telemetry, route, prepared, toolProtocol, dshContextSnapshot, persistentProtocolRepair, persistentProtocolRepairHint, persistentProtocolRepairReason, useToolBridge);
1634
+ persistentProtocolRepair = false;
1635
+ persistentProtocolRepairHint = undefined;
1636
+ persistentProtocolRepairReason = undefined;
1637
+ if (!outcome.retryWithFullPrompt) {
1638
+ // Success: the AGY conversation now holds exactly this turn's DSH
1639
+ // history, so the fingerprint chain advances to this request.
1640
+ if (sessionKey !== undefined && options.purpose === undefined && telemetry.conversationId !== undefined && telemetry.carrierAbortExpected !== true) {
1641
+ this.sessions.set(sessionKey, telemetry.conversationId, fingerprintAfterTurn(options));
1642
+ }
1643
+ telemetry.durationMs = Date.now() - telemetry.startedAt;
1644
+ emitAgyLog(this.logger, buildAgyLogRecord(telemetry, 'agy.request.completed'));
1645
+ return;
1646
+ }
1647
+ break;
1648
+ }
1649
+ catch (error) {
1650
+ if (toolProtocol !== undefined
1651
+ && isRepairableAgyRequestError(error)
1652
+ && persistentRepairAttempts < this.toolProtocolRepairRetries) {
1653
+ persistentRepairAttempts += 1;
1654
+ telemetry.protocolRepairAttempts += 1;
1655
+ persistentProtocolRepair = true;
1656
+ persistentProtocolRepairHint = protocolRepairHintOf(error);
1657
+ persistentProtocolRepairReason = protocolRepairReasonOf(error);
1658
+ if (persistentProtocolRepairReason !== undefined)
1659
+ telemetry.protocolRepairReason = persistentProtocolRepairReason;
1660
+ continue;
1661
+ }
1662
+ const errorCode = error instanceof Error && error.code !== undefined
1663
+ ? String(error.code)
1664
+ : undefined;
1665
+ if (errorCode === 'CONVERSATION_MISMATCH') {
1666
+ // The stored AGY conversation is not the one a fresh worker came
1667
+ // up with (stale id, evicted conversation, different route).
1668
+ // This is raised at worker start, before any delta, so dropping the
1669
+ // binding and falling through to the full-history one-shot turn
1670
+ // costs one resend and cannot truncate a visible answer.
1671
+ if (sessionKey !== undefined)
1672
+ this.sessions.delete(sessionKey);
1673
+ telemetry.conversationMismatch = true;
1674
+ break;
1675
+ }
1676
+ const beforeAccept = errorCode !== undefined
1677
+ ? ['WORKER_START_FAILED', 'WORKER_LIMIT', 'DISPOSED', 'ABORTED', 'WORKER_STOPPED'].includes(errorCode)
1678
+ : false;
1679
+ if (this.persistentFallback === 'before-accept' && beforeAccept) {
1680
+ // fallback to one-shot, keep telemetry for retry
1681
+ break;
1682
+ }
1683
+ throw error;
1684
+ }
1685
+ }
1686
+ }
1687
+ let requestedConversationId = sessionKey === undefined || this.sessionMode === 'full'
1688
+ ? undefined
1689
+ : this.sessions.get(sessionKey)?.conversationId;
1690
+ if (telemetry.conversationMismatch === true) {
1691
+ // A persistent worker could not continue the stored conversation; the
1692
+ // binding is already dropped, so this turn runs one-shot from the full
1693
+ // DSH history. Mark the mode so telemetry shows why it was resent.
1694
+ telemetry.requestMode = 'full-fallback';
1695
+ requestedConversationId = undefined;
1696
+ }
1697
+ let protocolRepairAttempts = 0;
1698
+ let protocolRepair = false;
1699
+ let protocolRepairHint;
1700
+ let protocolRepairReason;
1701
+ for (let attempt = 0; attempt < 2 + this.toolProtocolRepairRetries; attempt += 1) {
1702
+ telemetry.attempt = attempt + 1;
1703
+ let outcome;
1704
+ try {
1705
+ outcome = yield* this.streamAttempt(options, sessionKey, requestedConversationId, telemetry, route, prepared, toolProtocol, dshContextSnapshot, protocolRepair, protocolRepairHint, protocolRepairReason, useToolBridge);
1706
+ protocolRepair = false;
1707
+ protocolRepairHint = undefined;
1708
+ protocolRepairReason = undefined;
1709
+ }
1710
+ catch (error) {
1711
+ if (toolProtocol !== undefined
1712
+ && isRepairableAgyRequestError(error)
1713
+ && protocolRepairAttempts < this.toolProtocolRepairRetries) {
1714
+ protocolRepairAttempts += 1;
1715
+ telemetry.protocolRepairAttempts += 1;
1716
+ protocolRepair = true;
1717
+ protocolRepairHint = protocolRepairHintOf(error);
1718
+ protocolRepairReason = protocolRepairReasonOf(error);
1719
+ if (protocolRepairReason !== undefined) {
1720
+ telemetry.protocolRepairReason = protocolRepairReason;
1721
+ requestedConversationId = undefined;
1722
+ }
1723
+ continue;
1724
+ }
1725
+ throw error;
1726
+ }
1727
+ if (!outcome.retryWithFullPrompt) {
1728
+ // Success: the AGY conversation now holds exactly this turn's DSH
1729
+ // history, so the fingerprint chain advances to this request.
1730
+ if (sessionKey !== undefined && options.purpose === undefined && telemetry.conversationId !== undefined && telemetry.carrierAbortExpected !== true) {
1731
+ this.sessions.set(sessionKey, telemetry.conversationId, fingerprintAfterTurn(options));
1732
+ }
1733
+ telemetry.durationMs = Date.now() - telemetry.startedAt;
1734
+ emitAgyLog(this.logger, buildAgyLogRecord(telemetry, 'agy.request.completed'));
1735
+ return;
1736
+ }
1737
+ telemetry.requestMode = 'full-fallback';
1738
+ requestedConversationId = undefined;
1739
+ }
1740
+ throw new LlmError('AGY conversation could not be resumed; full DSH history retry failed', 'SESSION_RESUME_FAILED');
1741
+ }
1742
+ catch (error) {
1743
+ telemetry.processDiagnostic = agyProcessDiagnosticOf(error);
1744
+ const mapped = asLlmError(error);
1745
+ telemetry.bridgeOutcome = bridgeOutcomeForError(mapped.code, telemetry.bridgeOutcome);
1746
+ telemetry.durationMs = Date.now() - telemetry.startedAt;
1747
+ emitAgyLog(this.logger, buildAgyLogRecord(telemetry, 'agy.request.failed', mapped.code));
1748
+ throw mapped;
1749
+ }
1750
+ finally {
1751
+ telemetry.durationMs ??= Date.now() - telemetry.startedAt;
1752
+ await preparedPrompts?.cleanup();
1753
+ releaseProcess?.();
1754
+ releaseSession?.();
1755
+ }
1756
+ }
1757
+ async *streamAttempt(options, sessionKey, requestedConversationId, telemetry, route, prepared, toolProtocol, dshContextSnapshot, protocolRepair, protocolRepairHint, protocolRepairReason, mcpToolsEnabled = false) {
1758
+ if (toolProtocol !== undefined && (dshContextSnapshot?.state !== 'ready'
1759
+ || dshContextSnapshot.sessionState !== 'trusted'
1760
+ || dshContextSnapshot.workspaceState !== 'trusted')) {
1761
+ throw new DshContextError(DSH_CONTEXT_UNAVAILABLE_CODE);
1762
+ }
1763
+ telemetry.processAttemptCount += 1;
1764
+ const basePrompt = requestedConversationId === undefined
1765
+ ? prepared.fullPrompt
1766
+ : prepared.turnPrompt ?? serializeAgyTurnPrompt(options);
1767
+ const prompt = this.preparePrompt(basePrompt, toolProtocol, protocolRepair, protocolRepairHint, protocolRepairReason, telemetry);
1768
+ const addDirs = [
1769
+ ...(this.addDirs ?? []),
1770
+ ...(prepared.imageDirectory === undefined ? [] : [prepared.imageDirectory]),
1771
+ ...(prepared.imageCacheDirectory === undefined ? [] : [prepared.imageCacheDirectory]),
1772
+ ];
1773
+ const imageRuntime = prepared.bridgeActive ? this.imageAgent : undefined;
1774
+ const requestMode = imageRuntime?.mode ?? this.mode;
1775
+ const requestDisableSlashCommands = imageRuntime?.disableSlashCommands ?? this.disableSlashCommands;
1776
+ const queue = new AsyncQueue();
1777
+ const parser = new AgyStreamParser({ maxLineLength: this.maxEventLineLength });
1778
+ const controller = new AbortController();
1779
+ const forwardAbort = () => controller.abort();
1780
+ options.signal?.addEventListener('abort', forwardAbort, { once: true });
1781
+ let result;
1782
+ let settled = false;
1783
+ const request = {
1784
+ prompt,
1785
+ agent: imageRuntime?.agent ?? route.agent,
1786
+ model: route.model,
1787
+ timeoutMs: this.timeoutMs,
1788
+ idleTimeoutMs: this.idleTimeoutMs,
1789
+ maxStdoutBytes: this.maxOutputBytes,
1790
+ maxStderrBytes: this.maxOutputBytes,
1791
+ maxInputFrameBytes: this.inputFrameLimitBytes,
1792
+ stdinMode: this.stdinMode,
1793
+ signal: controller.signal,
1794
+ onStdoutLine: line => {
1795
+ for (const event of parser.push(`${line}\n`))
1796
+ queue.push(event);
1797
+ },
1798
+ ...(requestedConversationId === undefined ? {} : { conversation: requestedConversationId }),
1799
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
1800
+ ...(this.agyPath === undefined ? {} : { executable: this.agyPath }),
1801
+ ...(this.workspaceRoot === undefined ? {} : { cwd: this.workspaceRoot }),
1802
+ ...(addDirs.length === 0 ? {} : { addDirs }),
1803
+ ...(requestMode === undefined ? {} : { mode: requestMode }),
1804
+ ...(requestDisableSlashCommands === undefined ? {} : { disableSlashCommands: requestDisableSlashCommands }),
1805
+ };
1806
+ // Cold dispatch: the AGY process is spawned for this attempt, so the whole
1807
+ // startup cost (spawn, auth, MCP handshake) shows up before the first event.
1808
+ telemetry.dispatch = 'cold';
1809
+ const dispatchStartedAt = Date.now();
1810
+ const processPromise = this.run(request).then(processResultValue => {
1811
+ result = processResultValue;
1812
+ telemetry.process = processResultValue;
1813
+ settled = true;
1814
+ for (const event of parser.end())
1815
+ queue.push(event);
1816
+ queue.end();
1817
+ }).catch(error => {
1818
+ settled = true;
1819
+ queue.fail(error);
1820
+ });
1821
+ let blockStarted = false;
1822
+ // One consolidated reasoning block (index 1) records the bridged tool steps
1823
+ // and, when the turn was cut short, AGY's own reason for it. The block opens
1824
+ // lazily so a turn with neither keeps its plain text-only shape.
1825
+ let mcpStepsOpen = false;
1826
+ let mcpStepsText = '';
1827
+ let mcpStepCount = 0;
1828
+ let mcpBannerEmitted = false;
1829
+ let modelText = '';
1830
+ let resultSeen = false;
1831
+ let finalResponse;
1832
+ let finalStatus;
1833
+ let finalErrorDetail;
1834
+ // AGY's own reason when the turn failed. Kept apart from `finalErrorDetail`
1835
+ // because a failed turn can also carry a placeholder under `response`
1836
+ // ("(no content)"), and delivering a placeholder as the answer is worse
1837
+ // than delivering the partial text already streamed.
1838
+ let lastResultError;
1839
+ let latestStepUsage;
1840
+ let resultUsage;
1841
+ let fallbackUsage;
1842
+ let conversationMismatch = false;
1843
+ let activeConversationId;
1844
+ let permissionRequested = false;
1845
+ let carrierResponseAccepted = false;
1846
+ try {
1847
+ for await (const event of queue) {
1848
+ if (carrierResponseAccepted)
1849
+ continue;
1850
+ telemetry.firstEventMs ??= Date.now() - dispatchStartedAt;
1851
+ telemetry.eventCount += 1;
1852
+ telemetry.eventCategoryCounts[eventCategoryOf(event)] += 1;
1853
+ const observedConversationId = conversationIdOf(event);
1854
+ if (observedConversationId !== undefined) {
1855
+ activeConversationId = observedConversationId;
1856
+ telemetry.conversationId = observedConversationId;
1857
+ if (requestedConversationId !== undefined && observedConversationId !== requestedConversationId) {
1858
+ conversationMismatch = true;
1859
+ controller.abort();
1860
+ }
1861
+ else if (sessionKey !== undefined) {
1862
+ this.sessions.set(sessionKey, observedConversationId);
1863
+ }
1864
+ }
1865
+ recordToolEvent(telemetry, event, activeConversationId);
1866
+ if (conversationMismatch)
1867
+ continue;
1868
+ if (toolProtocol !== undefined) {
1869
+ const carrierResponse = dshOwnedCarrierResponseOf(event, toolProtocol, activeConversationId, telemetry);
1870
+ if (carrierResponse !== undefined) {
1871
+ carrierResponseAccepted = true;
1872
+ resultSeen = true;
1873
+ finalResponse = carrierResponse;
1874
+ finalStatus = 'SUCCESS';
1875
+ telemetry.finalStatus = finalStatus;
1876
+ telemetry.bridgeOutcome = 'dsh-tool-call';
1877
+ telemetry.carrierAbortExpected = true;
1878
+ // The wrapper conversation has just emitted an internal carrier.
1879
+ // Do not resume that potentially tainted AGY session on the next
1880
+ // DSH turn; the next request must start from the full DSH history.
1881
+ if (sessionKey !== undefined)
1882
+ this.sessions.delete(sessionKey);
1883
+ // No DSH service is attached to AGY's internal default_api in
1884
+ // this mode. Stop the wrapper after capturing the validated
1885
+ // envelope; runProcess maps this expected abort below.
1886
+ controller.abort();
1887
+ continue;
1888
+ }
1889
+ }
1890
+ const internalToolError = dshOwnedToolEventError(event, toolProtocol, { prepared, runtime: imageRuntime }, mcpToolsEnabled);
1891
+ if (internalToolError !== undefined) {
1892
+ telemetry.bridgeOutcome = 'agy-internal-tool';
1893
+ if (sessionKey !== undefined)
1894
+ this.sessions.delete(sessionKey);
1895
+ controller.abort();
1896
+ throw internalToolError;
1897
+ }
1898
+ if (isPermissionEvent(event))
1899
+ telemetry.permissionEventCount += 1;
1900
+ const errorDetail = errorDetailOf(event);
1901
+ if (errorDetail !== undefined)
1902
+ finalErrorDetail = errorDetail;
1903
+ if (isPermissionEvent(event)) {
1904
+ if (toolProtocol !== undefined)
1905
+ telemetry.bridgeOutcome = 'permission-required';
1906
+ permissionRequested = true;
1907
+ controller.abort();
1908
+ continue;
1909
+ }
1910
+ if (mcpToolsEnabled) {
1911
+ // Bridge mode keeps both channels live: AGY narrates through ordinary
1912
+ // text deltas and every DSH tool call arrives as an MCP step. The step
1913
+ // is recorded as a reasoning line, NOT as a DSH tool-call block. The
1914
+ // bridge has already run the tool; advertising it again makes the host
1915
+ // execute the block it just committed (`dsh-agent-loop` filters the
1916
+ // committed message for `tool-call` blocks and runs `executeToolCalls`
1917
+ // on them) — that replayed side effects, re-asked approval, and made
1918
+ // AGY answer the same work a second time.
1919
+ const step = mcpToolStepOf(event);
1920
+ if (step !== undefined) {
1921
+ const state = stepStateOf(event);
1922
+ if (state === 'DONE' || state === 'ERROR') {
1923
+ telemetry.toolCallCount += 1;
1924
+ telemetry.bridgeOutcome = 'mcp-tools';
1925
+ if (!mcpStepsOpen) {
1926
+ mcpStepsOpen = true;
1927
+ yield { type: 'block-start', index: 1, blockType: 'reasoning' };
1928
+ }
1929
+ if (!mcpBannerEmitted) {
1930
+ mcpBannerEmitted = true;
1931
+ mcpStepsText += reasoningBanner();
1932
+ yield { type: 'reasoning-delta', index: 1, text: reasoningBanner() };
1933
+ }
1934
+ const line = mcpStepProcessLine(step, state);
1935
+ mcpStepsText += line;
1936
+ mcpStepCount += 1;
1937
+ yield { type: 'reasoning-delta', index: 1, text: line };
1938
+ }
1939
+ // The MCP plumbing step carries no model text, but other step
1940
+ // shapes (agent_response) do, so fall through to the text channel
1941
+ // instead of skipping the event outright.
1942
+ if (step !== undefined)
1943
+ continue;
1944
+ }
1945
+ }
1946
+ const delta = toolProtocol === undefined ? textDeltaOf(event) : undefined;
1947
+ if (delta !== undefined && delta.length > 0) {
1948
+ telemetry.firstTextMs ??= Date.now() - dispatchStartedAt;
1949
+ if (!blockStarted) {
1950
+ blockStarted = true;
1951
+ yield { type: 'block-start', index: 0, blockType: 'text' };
1952
+ }
1953
+ modelText += delta;
1954
+ yield { type: 'text-delta', index: 0, text: delta };
1955
+ }
1956
+ if (event.event === 'result') {
1957
+ if (toolProtocol !== undefined && resultSeen) {
1958
+ controller.abort();
1959
+ throw new ToolProtocolError(TOOL_PROTOCOL_RESPONSE_INVALID_CODE, 'duplicate final response');
1960
+ }
1961
+ resultSeen = true;
1962
+ finalResponse = responseOf(event);
1963
+ finalStatus = statusOf(event);
1964
+ telemetry.finalStatus = finalStatus;
1965
+ resultUsage = usageOf(event);
1966
+ // The `result` event can carry AGY's own failure detail under
1967
+ // `error` (e.g. its AGY_ERROR trailer with `"retryable": true`).
1968
+ // Without this, `finalErrorDetail` stays empty and a failed turn
1969
+ // degrades to the detail-less `AGY returned status ERROR: ERROR`.
1970
+ if (finalErrorDetail === undefined) {
1971
+ const resultRecord = event.event === 'result' && isRecord(event.result) ? event.result : undefined;
1972
+ const resultError = typeof resultRecord?.error === 'string' ? resultRecord.error : undefined;
1973
+ if (resultError !== undefined && resultError.trim().length > 0)
1974
+ finalErrorDetail = resultError;
1975
+ }
1976
+ lastResultError = finalErrorDetail;
1977
+ if (telemetry.finalResultRaw === undefined)
1978
+ telemetry.finalResultRaw = finalResultRawOf(event);
1979
+ }
1980
+ const usage = usageOf(event);
1981
+ if (usage !== undefined && event.event === 'step_update')
1982
+ latestStepUsage = usage;
1983
+ else if (usage !== undefined && event.event !== 'result')
1984
+ fallbackUsage = usage;
1985
+ }
1986
+ await processPromise;
1987
+ }
1988
+ catch (error) {
1989
+ throw asLlmError(error);
1990
+ }
1991
+ finally {
1992
+ if (!settled)
1993
+ controller.abort();
1994
+ await processPromise;
1995
+ options.signal?.removeEventListener('abort', forwardAbort);
1996
+ }
1997
+ const selectedUsage = latestStepUsage ?? fallbackUsage ?? resultUsage;
1998
+ const usageSource = latestStepUsage !== undefined
1999
+ ? 'step'
2000
+ : selectedUsage === undefined ? undefined : 'result-fallback';
2001
+ const attemptUsage = selectedUsage === undefined ? undefined : mapAgyUsage(selectedUsage);
2002
+ if (attemptUsage !== undefined)
2003
+ recordUsageTelemetry(telemetry, attemptUsage, usageSource, resultUsage);
2004
+ if (conversationMismatch)
2005
+ return { retryWithFullPrompt: true };
2006
+ if (permissionRequested) {
2007
+ if (toolProtocol !== undefined)
2008
+ telemetry.bridgeOutcome = 'permission-required';
2009
+ throw new LlmError('AGY requested interactive permission; headless Provider cannot approve it. Adjust AGY Agent permissions or tool configuration', PERMISSION_REQUIRED_CODE);
2010
+ }
2011
+ const failure = carrierResponseAccepted
2012
+ ? undefined
2013
+ : result === undefined
2014
+ ? new LlmError('AGY process did not return a result', 'AGY_PROCESS')
2015
+ : processFailure(result);
2016
+ if (failure !== undefined)
2017
+ throw failure;
2018
+ // AGY reports `status: ERROR` on a turn it cut short, and it can carry a
2019
+ // placeholder under `response` ("(no content)") instead of the answer. When
2020
+ // the user already watched real text stream in, keep that text, park AGY's
2021
+ // reason as a reasoning annotation, and finish normally: throwing here makes
2022
+ // the host roll the stream back and replay the whole turn, and the replay
2023
+ // reads the same broken context, so it fails identically every attempt.
2024
+ // agy-link does exactly this (its `consumable` test at src/host/adapter.ts:
2025
+ // 1508 and the result branch at src/host/mapper.ts:400-428); the difference
2026
+ // is that a placeholder response is not deliverable content, so the streamed
2027
+ // text decides whether anything survives.
2028
+ const streamedPartialAnswer = resultSeen && !isSuccessStatus(finalStatus) && modelText.trim().length > 0;
2029
+ if (streamedPartialAnswer)
2030
+ telemetry.partialAnswerDelivered = true;
2031
+ if (resultSeen && !isSuccessStatus(finalStatus) && !streamedPartialAnswer) {
2032
+ const detail = [finalStatus, finalErrorDetail].filter(value => value !== undefined).join(' ');
2033
+ const code = classifyAgyFailure(detail, 'AGY_STATUS');
2034
+ throw new LlmError(safeAgyFailureMessage(`AGY returned status ${finalStatus}`, detail), code);
2035
+ }
2036
+ if (!resultSeen && finalErrorDetail !== undefined) {
2037
+ const code = classifyAgyFailure(finalErrorDetail, 'AGY_STATUS');
2038
+ throw new LlmError(safeAgyFailureMessage('AGY reported a failure', finalErrorDetail), code);
2039
+ }
2040
+ if (toolProtocol !== undefined) {
2041
+ if (finalResponse === undefined) {
2042
+ throw new ToolProtocolError(TOOL_PROTOCOL_RESPONSE_INVALID_CODE, 'missing final response');
2043
+ }
2044
+ let envelope;
2045
+ try {
2046
+ envelope = parseAgyToolEnvelope(finalResponse, toolProtocol, route.model, this.toolProtocolPlainTextFallback === 'final-message'
2047
+ && /^claude-/i.test(route.model), {
2048
+ onCompatibilityApplied: compatibility => {
2049
+ telemetry.protocolCompatibilityApplied = compatibility;
2050
+ },
2051
+ });
2052
+ }
2053
+ catch (error) {
2054
+ recordProtocolFailure(telemetry, finalResponse, error);
2055
+ throw error;
2056
+ }
2057
+ if (envelope.kind === 'message') {
2058
+ telemetry.bridgeOutcome = 'dsh-message';
2059
+ // An empty envelope message is a turn with nothing to say: emit no text
2060
+ // block rather than a blank one (a blank assistant message makes the
2061
+ // host refuse `session/follow` for the whole session).
2062
+ if (envelope.content.length > 0) {
2063
+ yield { type: 'block-start', index: 0, blockType: 'text' };
2064
+ yield { type: 'text-delta', index: 0, text: envelope.content };
2065
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: envelope.content } };
2066
+ }
2067
+ if (attemptUsage !== undefined)
2068
+ yield { type: 'usage', usage: attemptUsage };
2069
+ yield { type: 'finish', reason: { kind: 'stop' } };
2070
+ return { retryWithFullPrompt: false };
2071
+ }
2072
+ const callId = ToolCallId(randomUUID());
2073
+ const argumentsJson = JSON.stringify(envelope.arguments);
2074
+ telemetry.toolCallCount += 1;
2075
+ telemetry.bridgeOutcome = 'dsh-tool-call';
2076
+ yield { type: 'block-start', index: 0, blockType: 'tool-call' };
2077
+ yield {
2078
+ type: 'tool-call-delta',
2079
+ index: 0,
2080
+ id: callId,
2081
+ name: envelope.name,
2082
+ argumentsDelta: argumentsJson,
2083
+ };
2084
+ yield {
2085
+ type: 'block-end',
2086
+ index: 0,
2087
+ block: {
2088
+ type: 'tool-call',
2089
+ id: callId,
2090
+ name: envelope.name,
2091
+ arguments: argumentsJson,
2092
+ },
2093
+ };
2094
+ if (attemptUsage !== undefined)
2095
+ yield { type: 'usage', usage: attemptUsage };
2096
+ yield { type: 'finish', reason: { kind: 'tool-calls' } };
2097
+ return { retryWithFullPrompt: false };
2098
+ }
2099
+ // A failed turn's `response` is a placeholder ("(no content)"); appending it
2100
+ // would write that placeholder into the answer. The streamed text is the
2101
+ // real partial answer, so leave it as it stands.
2102
+ const appendFinalResponse = !streamedPartialAnswer && finalResponse !== undefined && finalResponse.trim().length > 0;
2103
+ if (appendFinalResponse && finalResponse !== undefined) {
2104
+ const suffix = modelText.length === 0
2105
+ ? finalResponse
2106
+ : finalResponse.startsWith(modelText) ? finalResponse.slice(modelText.length) : '';
2107
+ if (suffix.length > 0) {
2108
+ if (!blockStarted) {
2109
+ blockStarted = true;
2110
+ yield { type: 'block-start', index: 0, blockType: 'text' };
2111
+ }
2112
+ modelText += suffix;
2113
+ yield { type: 'text-delta', index: 0, text: suffix };
2114
+ }
2115
+ }
2116
+ if (blockStarted) {
2117
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: modelText } };
2118
+ }
2119
+ // AGY's own reason for a cut-short turn belongs in the same reasoning block
2120
+ // as the bridged tool steps, so the transcript gets one annotation, not two.
2121
+ if (streamedPartialAnswer) {
2122
+ const reason = (lastResultError ?? finalErrorDetail ?? 'AGY reported status ERROR').trim();
2123
+ const line = `[agy finished with error] ${reason}\n\n`;
2124
+ if (!mcpStepsOpen) {
2125
+ mcpStepsOpen = true;
2126
+ yield { type: 'block-start', index: 1, blockType: 'reasoning' };
2127
+ }
2128
+ if (!mcpBannerEmitted) {
2129
+ mcpBannerEmitted = true;
2130
+ mcpStepsText += reasoningBanner();
2131
+ yield { type: 'reasoning-delta', index: 1, text: reasoningBanner() };
2132
+ }
2133
+ mcpStepsText += line;
2134
+ yield { type: 'reasoning-delta', index: 1, text: line };
2135
+ }
2136
+ const mcpCloseLine = mcpStepCount > 0 ? reasoningCloseLine(mcpStepCount) : '';
2137
+ mcpStepsText = `${mcpStepsText}${mcpCloseLine}`;
2138
+ if (mcpStepsOpen) {
2139
+ if (mcpCloseLine !== '') {
2140
+ yield { type: 'reasoning-delta', index: 1, text: mcpCloseLine };
2141
+ }
2142
+ yield { type: 'block-end', index: 1, block: { type: 'reasoning', text: mcpStepsText } };
2143
+ }
2144
+ else if (!blockStarted && telemetry.toolCallCount === 0) {
2145
+ throw new LlmError('AGY completed without a text response', EMPTY_RESPONSE_CODE);
2146
+ }
2147
+ // A tool-only turn with no narration and no recorded step still succeeds:
2148
+ // an empty text block is not harmless — it lands in the transcript as a
2149
+ // blank assistant message, and a session holding one is refused by the
2150
+ // host's `session/follow` (pocket then retries it forever). Throwing
2151
+ // EMPTY_RESPONSE would be worse, since the code is retryable and would
2152
+ // re-run side effects that already happened.
2153
+ if (attemptUsage !== undefined)
2154
+ yield { type: 'usage', usage: attemptUsage };
2155
+ yield { type: 'finish', reason: { kind: 'stop' } };
2156
+ return { retryWithFullPrompt: false };
2157
+ }
2158
+ }
2159
+ //# sourceMappingURL=agy.js.map