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.
- package/LICENSE +31 -0
- package/README.en.md +48 -0
- package/README.md +67 -2
- package/agents/dsh-agy-image-view.md +17 -0
- package/agents/dsh-agy-mcp-executor.md +32 -0
- package/agents/dsh-agy-read-only.md +20 -0
- package/agents/dsh-agy-tool-free.md +16 -0
- package/agents/dsh-agy-workspace-write.md +23 -0
- package/bin/dsh-agy-cli.js +9 -0
- package/bin/win32-x64/agy-launcher.exe +0 -0
- package/cordis.patch.yml +16 -0
- package/dist/client.js +4259 -0
- package/lib/agent-installer.d.ts +66 -0
- package/lib/agent-installer.js +147 -0
- package/lib/agent-presets.d.ts +42 -0
- package/lib/agent-presets.js +130 -0
- package/lib/agents-cli.d.ts +3 -0
- package/lib/agents-cli.js +110 -0
- package/lib/agy/diagnostics.d.ts +29 -0
- package/lib/agy/diagnostics.js +109 -0
- package/lib/agy/errors.d.ts +20 -0
- package/lib/agy/errors.js +77 -0
- package/lib/agy/experimental-transport.d.ts +79 -0
- package/lib/agy/experimental-transport.js +612 -0
- package/lib/agy/keepalive.d.ts +140 -0
- package/lib/agy/keepalive.js +445 -0
- package/lib/agy/limiter.d.ts +32 -0
- package/lib/agy/limiter.js +111 -0
- package/lib/agy/log.d.ts +214 -0
- package/lib/agy/log.js +503 -0
- package/lib/agy/models.d.ts +49 -0
- package/lib/agy/models.js +203 -0
- package/lib/agy/overrides.d.ts +17 -0
- package/lib/agy/overrides.js +73 -0
- package/lib/agy/parser.d.ts +102 -0
- package/lib/agy/parser.js +492 -0
- package/lib/agy/persistent-transport.d.ts +101 -0
- package/lib/agy/persistent-transport.js +753 -0
- package/lib/agy/process.d.ts +144 -0
- package/lib/agy/process.js +452 -0
- package/lib/agy/redact.d.ts +3 -0
- package/lib/agy/redact.js +24 -0
- package/lib/agy/stream-protocol.d.ts +50 -0
- package/lib/agy/stream-protocol.js +156 -0
- package/lib/agy/windows-launcher.d.ts +21 -0
- package/lib/agy/windows-launcher.js +76 -0
- package/lib/diagnostics.d.ts +78 -0
- package/lib/diagnostics.js +162 -0
- package/lib/doctor.d.ts +138 -0
- package/lib/doctor.js +940 -0
- package/lib/dsh/context.d.ts +133 -0
- package/lib/dsh/context.js +352 -0
- package/lib/dsh-home.d.ts +6 -0
- package/lib/dsh-home.js +11 -0
- package/lib/index.d.ts +54 -0
- package/lib/index.js +267 -0
- package/lib/mcp/agy-registration.d.ts +70 -0
- package/lib/mcp/agy-registration.js +245 -0
- package/lib/mcp/bridge.d.ts +73 -0
- package/lib/mcp/bridge.js +295 -0
- package/lib/mcp/endpoint.d.ts +41 -0
- package/lib/mcp/endpoint.js +142 -0
- package/lib/mcp/protocol.d.ts +107 -0
- package/lib/mcp/protocol.js +197 -0
- package/lib/mcp/tool-projection.d.ts +64 -0
- package/lib/mcp/tool-projection.js +168 -0
- package/lib/provider/agy.d.ts +153 -0
- package/lib/provider/agy.js +2159 -0
- package/lib/provider/config.d.ts +226 -0
- package/lib/provider/config.js +294 -0
- package/lib/provider/error-codes.d.ts +22 -0
- package/lib/provider/error-codes.js +29 -0
- package/lib/provider/image-bridge.d.ts +60 -0
- package/lib/provider/image-bridge.js +290 -0
- package/lib/provider/mock.d.ts +15 -0
- package/lib/provider/mock.js +78 -0
- package/lib/provider/prompt-budget.d.ts +45 -0
- package/lib/provider/prompt-budget.js +596 -0
- package/lib/provider/serialize.d.ts +15 -0
- package/lib/provider/serialize.js +82 -0
- package/lib/provider/tool-protocol.d.ts +80 -0
- package/lib/provider/tool-protocol.js +848 -0
- package/lib/provider/tool-schema-file.d.ts +11 -0
- package/lib/provider/tool-schema-file.js +27 -0
- package/lib/session/file-store.d.ts +56 -0
- package/lib/session/file-store.js +186 -0
- package/lib/session/store.d.ts +49 -0
- package/lib/session/store.js +62 -0
- package/lib/web/router.d.ts +19 -0
- package/lib/web/router.js +994 -0
- 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
|