glm-acp-agent 1.1.3 → 1.2.0
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/README.md +118 -51
- package/dist/index.js +2 -2
- package/dist/llm/glm-client.d.ts +59 -1
- package/dist/llm/glm-client.d.ts.map +1 -1
- package/dist/llm/glm-client.js +135 -15
- package/dist/llm/glm-client.js.map +1 -1
- package/dist/protocol/agent.d.ts +16 -3
- package/dist/protocol/agent.d.ts.map +1 -1
- package/dist/protocol/agent.js +422 -49
- package/dist/protocol/agent.js.map +1 -1
- package/dist/protocol/image-preprocessor.js +1 -1
- package/dist/protocol/image-preprocessor.js.map +1 -1
- package/dist/protocol/session-store.d.ts +14 -2
- package/dist/protocol/session-store.d.ts.map +1 -1
- package/dist/protocol/session-store.js +27 -6
- package/dist/protocol/session-store.js.map +1 -1
- package/dist/protocol/system-prompt.d.ts.map +1 -1
- package/dist/protocol/system-prompt.js +1 -3
- package/dist/protocol/system-prompt.js.map +1 -1
- package/dist/tests/agent.test.js +395 -4
- package/dist/tests/agent.test.js.map +1 -1
- package/dist/tests/executor.test.js +69 -2
- package/dist/tests/executor.test.js.map +1 -1
- package/dist/tests/glm-client.test.js +152 -2
- package/dist/tests/glm-client.test.js.map +1 -1
- package/dist/tests/integration.test.js +50 -0
- package/dist/tests/integration.test.js.map +1 -1
- package/dist/tools/executor.d.ts +10 -1
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +77 -53
- package/dist/tools/executor.js.map +1 -1
- package/dist/tools/session-mcp-client.js +3 -1
- package/dist/tools/session-mcp-client.js.map +1 -1
- package/dist/tools/vision-mcp-client.d.ts.map +1 -1
- package/dist/tools/vision-mcp-client.js +6 -2
- package/dist/tools/vision-mcp-client.js.map +1 -1
- package/package.json +8 -4
package/dist/protocol/agent.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join as pathJoin } from "node:path";
|
|
4
4
|
import { PROTOCOL_VERSION as VERSION } from "@agentclientprotocol/sdk";
|
|
5
|
-
import { GlmClient, getAvailableModels, getDefaultModel, } from "../llm/glm-client.js";
|
|
5
|
+
import { GlmClient, getAvailableModels, getDefaultModel, getContextWindow, isVisionNativeModel, ERR_CONTEXT_OVERFLOW, getThoughtLevels, resolveThoughtLevel, isThoughtLevel, } from "../llm/glm-client.js";
|
|
6
6
|
import { ToolExecutor } from "../tools/executor.js";
|
|
7
7
|
import { TOOL_DEFINITIONS } from "../tools/definitions.js";
|
|
8
8
|
import { connectSessionMcpServers } from "../tools/session-mcp-client.js";
|
|
@@ -119,7 +119,7 @@ export class GlmAcpAgent {
|
|
|
119
119
|
promptCapabilities: {
|
|
120
120
|
// Baseline (text + resource_link) is implicit; we additionally accept
|
|
121
121
|
// embedded resources for inline file context, plus images for
|
|
122
|
-
//
|
|
122
|
+
// glm-5v-turbo native vision and the Vision MCP fallback path.
|
|
123
123
|
// Set ACP_GLM_PROMPT_IMAGES=false (or =0) to stop advertising image
|
|
124
124
|
// support so clients like Zed won't offer the image-attachment UI.
|
|
125
125
|
embeddedContext: true,
|
|
@@ -135,11 +135,12 @@ export class GlmAcpAgent {
|
|
|
135
135
|
},
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
-
async authenticate(
|
|
138
|
+
async authenticate(params) {
|
|
139
139
|
// Authentication is configured externally — either via Z_AI_API_KEY in the
|
|
140
140
|
// environment or via the credentials file written by `glm-acp-agent --setup`.
|
|
141
141
|
// The agent has nothing to do here; failures will surface when the model
|
|
142
142
|
// is first called.
|
|
143
|
+
void params;
|
|
143
144
|
return {};
|
|
144
145
|
}
|
|
145
146
|
async newSession(params) {
|
|
@@ -156,6 +157,10 @@ export class GlmAcpAgent {
|
|
|
156
157
|
}),
|
|
157
158
|
};
|
|
158
159
|
const model = getDefaultModel();
|
|
160
|
+
// Default to the model's own default effort ("max" for 5.2, "on"
|
|
161
|
+
// otherwise) so out-of-the-box behaviour matches the pre-thought-level
|
|
162
|
+
// default (thinking on, no explicit reasoning_effort).
|
|
163
|
+
const thoughtLevel = resolveThoughtLevel(model, "max");
|
|
159
164
|
this.sessions.set(sessionId, {
|
|
160
165
|
cwd: params.cwd,
|
|
161
166
|
messages: [systemPrompt],
|
|
@@ -166,10 +171,14 @@ export class GlmAcpAgent {
|
|
|
166
171
|
model,
|
|
167
172
|
toolDefinitions,
|
|
168
173
|
mcpTools,
|
|
174
|
+
mode: "default",
|
|
175
|
+
thoughtLevel,
|
|
169
176
|
});
|
|
170
177
|
return {
|
|
171
178
|
sessionId,
|
|
172
179
|
models: this.modelsState(model),
|
|
180
|
+
modes: this.modesState("default"),
|
|
181
|
+
configOptions: this.configOptionsState(model, thoughtLevel),
|
|
173
182
|
};
|
|
174
183
|
}
|
|
175
184
|
async unstable_setSessionModel(params) {
|
|
@@ -185,7 +194,14 @@ export class GlmAcpAgent {
|
|
|
185
194
|
process.stderr.write(`[glm-acp-agent] warning: model "${params.modelId}" is not in the advertised list; using as-is.\n`);
|
|
186
195
|
}
|
|
187
196
|
session.model = params.modelId;
|
|
197
|
+
// The valid thought levels differ per model (e.g. 5.2 offers high/max
|
|
198
|
+
// while others only offer none/on), so clamp the current level to what
|
|
199
|
+
// the new model supports.
|
|
200
|
+
session.thoughtLevel = resolveThoughtLevel(params.modelId, session.thoughtLevel);
|
|
188
201
|
session.updatedAt = new Date().toISOString();
|
|
202
|
+
// Persist immediately so a fork/reload before the next prompt doesn't
|
|
203
|
+
// resurrect the previous model or thought level (matches setSessionConfigOption).
|
|
204
|
+
this.persistSession(params.sessionId, session);
|
|
189
205
|
// Notify clients so any UI that displays the active model refreshes
|
|
190
206
|
// immediately, instead of waiting for the next prompt to complete.
|
|
191
207
|
await safeSessionUpdate(this.connection, {
|
|
@@ -195,8 +211,66 @@ export class GlmAcpAgent {
|
|
|
195
211
|
updatedAt: session.updatedAt,
|
|
196
212
|
},
|
|
197
213
|
});
|
|
214
|
+
// Push updated thought_level options — the set of valid levels may
|
|
215
|
+
// differ between models (e.g. switching from 5.2 to 4.7 drops "high").
|
|
216
|
+
await safeSessionUpdate(this.connection, {
|
|
217
|
+
sessionId: params.sessionId,
|
|
218
|
+
update: {
|
|
219
|
+
sessionUpdate: "config_option_update",
|
|
220
|
+
configOptions: this.configOptionsState(session.model, session.thoughtLevel),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
198
223
|
return {};
|
|
199
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the `thought_level` SessionConfigOption for the given model. The
|
|
227
|
+
* available levels depend on the model (see {@link getThoughtLevels}).
|
|
228
|
+
*/
|
|
229
|
+
configOptionsState(model, thoughtLevel) {
|
|
230
|
+
const levels = getThoughtLevels(model);
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
id: "thought_level",
|
|
234
|
+
name: "Thinking",
|
|
235
|
+
description: "Reasoning effort",
|
|
236
|
+
category: "thought_level",
|
|
237
|
+
type: "select",
|
|
238
|
+
currentValue: thoughtLevel,
|
|
239
|
+
options: levels.map((level) => ({
|
|
240
|
+
value: level,
|
|
241
|
+
name: level === "none"
|
|
242
|
+
? "Off"
|
|
243
|
+
: level === "on"
|
|
244
|
+
? "On"
|
|
245
|
+
: level.charAt(0).toUpperCase() + level.slice(1),
|
|
246
|
+
})),
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
async setSessionConfigOption(params) {
|
|
251
|
+
const session = this.sessions.get(params.sessionId);
|
|
252
|
+
if (!session) {
|
|
253
|
+
throw new Error(`Session not found: ${params.sessionId}`);
|
|
254
|
+
}
|
|
255
|
+
if (params.configId !== "thought_level") {
|
|
256
|
+
throw new Error(`Unknown config option: ${params.configId}`);
|
|
257
|
+
}
|
|
258
|
+
// Reject values that aren't a thought level at all (typos, stale ids from
|
|
259
|
+
// another agent version) rather than silently coercing them.
|
|
260
|
+
if (typeof params.value !== "string" || !isThoughtLevel(params.value)) {
|
|
261
|
+
throw new Error(`Invalid thought_level value: ${String(params.value)}`);
|
|
262
|
+
}
|
|
263
|
+
// Auto-resolve a *known* level that isn't valid for the current model to
|
|
264
|
+
// that model's default. This happens when a client caches a thoughtLevel
|
|
265
|
+
// from a previous session (or model) and re-sends it for a model with a
|
|
266
|
+
// different level set (e.g. "max" from glm-5.2 sent while on glm-4.7).
|
|
267
|
+
session.thoughtLevel = resolveThoughtLevel(session.model, params.value);
|
|
268
|
+
session.updatedAt = new Date().toISOString();
|
|
269
|
+
this.persistSession(params.sessionId, session);
|
|
270
|
+
return {
|
|
271
|
+
configOptions: this.configOptionsState(session.model, session.thoughtLevel),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
200
274
|
/** Build the SessionModelState we advertise on session create/load/resume/fork. */
|
|
201
275
|
modelsState(currentModelId) {
|
|
202
276
|
return {
|
|
@@ -204,9 +278,49 @@ export class GlmAcpAgent {
|
|
|
204
278
|
currentModelId,
|
|
205
279
|
};
|
|
206
280
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
281
|
+
/** Build the SessionModeState we advertise on session create/load/resume/fork. */
|
|
282
|
+
modesState(currentModeId) {
|
|
283
|
+
return {
|
|
284
|
+
availableModes: [
|
|
285
|
+
{
|
|
286
|
+
id: "default",
|
|
287
|
+
name: "Ask for permission",
|
|
288
|
+
description: "Prompt before edits and commands.",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
id: "accept_edits",
|
|
292
|
+
name: "Auto-approve edits",
|
|
293
|
+
description: "Edits run without prompting. Commands still prompt.",
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "bypass_permissions",
|
|
297
|
+
name: "Bypass all permissions",
|
|
298
|
+
description: "Edits and commands run without prompting.",
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
currentModeId,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
async setSessionMode(params) {
|
|
305
|
+
const session = this.sessions.get(params.sessionId);
|
|
306
|
+
if (!session) {
|
|
307
|
+
throw new Error(`Session not found: ${params.sessionId}`);
|
|
308
|
+
}
|
|
309
|
+
const validModeIds = ["default", "accept_edits", "bypass_permissions"];
|
|
310
|
+
if (!validModeIds.includes(params.modeId)) {
|
|
311
|
+
throw new Error(`Invalid modeId: ${params.modeId}. Valid modes are: ${validModeIds.join(", ")}`);
|
|
312
|
+
}
|
|
313
|
+
const newMode = params.modeId;
|
|
314
|
+
session.mode = newMode;
|
|
315
|
+
session.updatedAt = new Date().toISOString();
|
|
316
|
+
this.persistSession(params.sessionId, session);
|
|
317
|
+
await safeSessionUpdate(this.connection, {
|
|
318
|
+
sessionId: params.sessionId,
|
|
319
|
+
update: {
|
|
320
|
+
sessionUpdate: "current_mode_update",
|
|
321
|
+
currentModeId: newMode,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
210
324
|
return {};
|
|
211
325
|
}
|
|
212
326
|
// ---------------------------------------------------------------------------
|
|
@@ -244,18 +358,21 @@ export class GlmAcpAgent {
|
|
|
244
358
|
else if (connSignal?.aborted) {
|
|
245
359
|
abortController.abort();
|
|
246
360
|
}
|
|
247
|
-
// Convert ACP content blocks into a GLM user message.
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// result is always a plain string for the chat-completions endpoint.
|
|
361
|
+
// Convert ACP content blocks into a GLM user message. Most models receive
|
|
362
|
+
// plain text, with images preprocessed through Vision MCP. Native vision
|
|
363
|
+
// models receive OpenAI-style multimodal content parts directly.
|
|
251
364
|
if (isDebugEnabled()) {
|
|
252
365
|
for (const line of buildPromptBlockDiagnosticLines(params.prompt)) {
|
|
253
366
|
debug(line);
|
|
254
367
|
}
|
|
255
368
|
}
|
|
256
|
-
|
|
257
|
-
preprocessed =
|
|
258
|
-
|
|
369
|
+
const visionNative = isVisionNativeModel(session.model);
|
|
370
|
+
const preprocessed = visionNative
|
|
371
|
+
? { blocks: params.prompt, cleanups: [] }
|
|
372
|
+
: await preprocessImageBlocks(params.prompt, this.visionClient, abortController.signal);
|
|
373
|
+
const { content: userContent, plainText: userText } = visionNative
|
|
374
|
+
? renderVisionNativePromptBlocks(preprocessed.blocks)
|
|
375
|
+
: renderPromptBlocks(preprocessed.blocks);
|
|
259
376
|
session.messages.push({ role: "user", content: userContent });
|
|
260
377
|
// Echo back the client-supplied messageId on every response (success,
|
|
261
378
|
// cancelled, or error) so the client can correlate the turn.
|
|
@@ -406,13 +523,19 @@ export class GlmAcpAgent {
|
|
|
406
523
|
model: persisted.model,
|
|
407
524
|
toolDefinitions,
|
|
408
525
|
mcpTools,
|
|
526
|
+
mode: persisted.mode,
|
|
527
|
+
thoughtLevel: resolveThoughtLevel(persisted.model, persisted.thoughtLevel ?? "max"),
|
|
409
528
|
};
|
|
410
529
|
this.sessions.set(params.sessionId, restored);
|
|
411
530
|
// Replay user/assistant text turns so the client can rehydrate its UI.
|
|
412
531
|
// Tool / system messages are skipped — they're internal and the client
|
|
413
532
|
// doesn't render them on its own.
|
|
414
533
|
await this.replayMessages(params.sessionId, persisted.messages);
|
|
415
|
-
return {
|
|
534
|
+
return {
|
|
535
|
+
models: this.modelsState(persisted.model),
|
|
536
|
+
modes: this.modesState(persisted.mode),
|
|
537
|
+
configOptions: this.configOptionsState(restored.model, restored.thoughtLevel),
|
|
538
|
+
};
|
|
416
539
|
}
|
|
417
540
|
async unstable_forkSession(params) {
|
|
418
541
|
const source = this.sessions.get(params.sessionId);
|
|
@@ -434,12 +557,16 @@ export class GlmAcpAgent {
|
|
|
434
557
|
model: persisted.model,
|
|
435
558
|
toolDefinitions,
|
|
436
559
|
mcpTools,
|
|
560
|
+
mode: persisted.mode,
|
|
561
|
+
thoughtLevel: resolveThoughtLevel(persisted.model, persisted.thoughtLevel ?? "max"),
|
|
437
562
|
};
|
|
438
563
|
this.sessions.set(newSessionId, forked);
|
|
439
564
|
this.persistSession(newSessionId, forked);
|
|
440
565
|
return {
|
|
441
566
|
sessionId: newSessionId,
|
|
442
567
|
models: this.modelsState(forked.model),
|
|
568
|
+
modes: this.modesState(forked.mode),
|
|
569
|
+
configOptions: this.configOptionsState(forked.model, forked.thoughtLevel),
|
|
443
570
|
};
|
|
444
571
|
}
|
|
445
572
|
async resumeSession(params) {
|
|
@@ -457,11 +584,17 @@ export class GlmAcpAgent {
|
|
|
457
584
|
model: persisted.model,
|
|
458
585
|
toolDefinitions,
|
|
459
586
|
mcpTools,
|
|
587
|
+
mode: persisted.mode,
|
|
588
|
+
thoughtLevel: resolveThoughtLevel(persisted.model, persisted.thoughtLevel ?? "max"),
|
|
460
589
|
};
|
|
461
590
|
this.sessions.set(params.sessionId, restored);
|
|
462
591
|
// Resume does NOT replay history — the client keeps its own UI state and
|
|
463
592
|
// just wants the agent to pick up where it left off.
|
|
464
|
-
return {
|
|
593
|
+
return {
|
|
594
|
+
models: this.modelsState(persisted.model),
|
|
595
|
+
modes: this.modesState(persisted.mode),
|
|
596
|
+
configOptions: this.configOptionsState(restored.model, restored.thoughtLevel),
|
|
597
|
+
};
|
|
465
598
|
}
|
|
466
599
|
// ---------------------------------------------------------------------------
|
|
467
600
|
// Persistence helpers
|
|
@@ -474,6 +607,8 @@ export class GlmAcpAgent {
|
|
|
474
607
|
title: session.title,
|
|
475
608
|
updatedAt: session.updatedAt,
|
|
476
609
|
model: session.model,
|
|
610
|
+
mode: session.mode,
|
|
611
|
+
thoughtLevel: session.thoughtLevel,
|
|
477
612
|
};
|
|
478
613
|
}
|
|
479
614
|
persistSession(sessionId, session) {
|
|
@@ -546,53 +681,90 @@ export class GlmAcpAgent {
|
|
|
546
681
|
* Returns the ACP stop reason and optional token usage reported by the model.
|
|
547
682
|
*/
|
|
548
683
|
async runPromptLoop(sessionId, session, signal) {
|
|
549
|
-
const executor = new ToolExecutor(this.connection, sessionId, this.clientCapabilities, signal, this.visionClient, session.mcpTools, session.cwd
|
|
684
|
+
const executor = new ToolExecutor(this.connection, sessionId, this.clientCapabilities, signal, this.visionClient, session.mcpTools, session.cwd,
|
|
685
|
+
// Use a thunk so mode changes mid-turn take effect on the next tool call.
|
|
686
|
+
() => this.sessions.get(sessionId)?.mode ?? "default");
|
|
550
687
|
let lastUsage;
|
|
688
|
+
let overflowRetryCount = 0;
|
|
551
689
|
for (let turn = 0; turn < this.maxTurns; turn++) {
|
|
552
690
|
if (signal.aborted)
|
|
553
691
|
return { stopReason: "cancelled" };
|
|
692
|
+
// Proactive compaction: check if history exceeds 90% of context window.
|
|
693
|
+
const window = getContextWindow(session.model);
|
|
694
|
+
const limit = Math.floor(window * 0.9);
|
|
695
|
+
if (estimateTokens(session.messages) > limit) {
|
|
696
|
+
session.messages = compactMessages(session.messages, Math.floor(window * 0.8));
|
|
697
|
+
}
|
|
554
698
|
debug(`promptLoop: turn=${turn} session=${sessionId} model=${session.model} messages=${session.messages.length}`);
|
|
555
699
|
const toolCalls = [];
|
|
556
700
|
let assistantText = "";
|
|
557
701
|
let lastStopReason;
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
702
|
+
let retryTurn = false;
|
|
703
|
+
try {
|
|
704
|
+
// Stream the GLM response. The session's currently-selected model wins;
|
|
705
|
+
// it's mutated by `unstable_setSessionModel` between turns.
|
|
706
|
+
for await (const chunk of this.glm.streamChat(session.messages, signal, {
|
|
707
|
+
model: session.model,
|
|
708
|
+
tools: session.toolDefinitions,
|
|
709
|
+
reasoningEffort: session.thoughtLevel,
|
|
710
|
+
})) {
|
|
711
|
+
if (signal.aborted)
|
|
712
|
+
return { stopReason: "cancelled" };
|
|
713
|
+
if (chunk.thinking) {
|
|
714
|
+
await this.connection.sessionUpdate({
|
|
715
|
+
sessionId,
|
|
716
|
+
update: {
|
|
717
|
+
sessionUpdate: "agent_thought_chunk",
|
|
718
|
+
content: { type: "text", text: chunk.thinking },
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (chunk.text) {
|
|
723
|
+
assistantText += chunk.text;
|
|
724
|
+
await this.connection.sessionUpdate({
|
|
725
|
+
sessionId,
|
|
726
|
+
update: {
|
|
727
|
+
sessionUpdate: "agent_message_chunk",
|
|
728
|
+
content: { type: "text", text: chunk.text },
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
if (chunk.toolCall) {
|
|
733
|
+
debug(`promptLoop: toolCall id=${chunk.toolCall.id} name=${chunk.toolCall.name}`);
|
|
734
|
+
toolCalls.push(chunk.toolCall);
|
|
735
|
+
}
|
|
736
|
+
if (chunk.usage) {
|
|
737
|
+
lastUsage = chunk.usage;
|
|
738
|
+
}
|
|
739
|
+
if (chunk.done) {
|
|
740
|
+
lastStopReason = chunk.stopReason;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
564
745
|
if (signal.aborted)
|
|
565
746
|
return { stopReason: "cancelled" };
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
747
|
+
const body = err?.error;
|
|
748
|
+
const isOverflow = body?.code === ERR_CONTEXT_OVERFLOW ||
|
|
749
|
+
body?.code === String(ERR_CONTEXT_OVERFLOW);
|
|
750
|
+
if (isOverflow && overflowRetryCount < 1) {
|
|
751
|
+
debug(`promptLoop: context overflow (1261) detected, performing emergency compaction`);
|
|
752
|
+
const window = getContextWindow(session.model);
|
|
753
|
+
session.messages = compactMessages(session.messages, Math.floor(window * 0.7));
|
|
754
|
+
overflowRetryCount++;
|
|
755
|
+
retryTurn = true;
|
|
574
756
|
}
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
await this.connection.sessionUpdate({
|
|
578
|
-
sessionId,
|
|
579
|
-
update: {
|
|
580
|
-
sessionUpdate: "agent_message_chunk",
|
|
581
|
-
content: { type: "text", text: chunk.text },
|
|
582
|
-
},
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
if (chunk.toolCall) {
|
|
586
|
-
debug(`promptLoop: toolCall id=${chunk.toolCall.id} name=${chunk.toolCall.name}`);
|
|
587
|
-
toolCalls.push(chunk.toolCall);
|
|
757
|
+
else if (isOverflow) {
|
|
758
|
+
throw new Error("Context overflow persisted after emergency compaction", { cause: err });
|
|
588
759
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
if (chunk.done) {
|
|
593
|
-
lastStopReason = chunk.stopReason;
|
|
760
|
+
else {
|
|
761
|
+
throw err;
|
|
594
762
|
}
|
|
595
763
|
}
|
|
764
|
+
if (retryTurn) {
|
|
765
|
+
turn--; // Re-run the same turn index
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
596
768
|
// Record the assistant turn in history so the model has full context for
|
|
597
769
|
// the next iteration.
|
|
598
770
|
if (toolCalls.length > 0) {
|
|
@@ -702,9 +874,115 @@ function renderPromptBlocks(blocks) {
|
|
|
702
874
|
const plainText = textParts.join("\n");
|
|
703
875
|
return { content: plainText, plainText };
|
|
704
876
|
}
|
|
877
|
+
function renderVisionNativePromptBlocks(blocks) {
|
|
878
|
+
const content = [];
|
|
879
|
+
const plainTextParts = [];
|
|
880
|
+
let imageIndex = 0;
|
|
881
|
+
const pushText = (text) => {
|
|
882
|
+
if (text.length === 0)
|
|
883
|
+
return;
|
|
884
|
+
content.push({ type: "text", text });
|
|
885
|
+
plainTextParts.push(text);
|
|
886
|
+
};
|
|
887
|
+
for (const block of blocks) {
|
|
888
|
+
switch (block.type) {
|
|
889
|
+
case "text":
|
|
890
|
+
pushText(block.text);
|
|
891
|
+
break;
|
|
892
|
+
case "resource_link":
|
|
893
|
+
pushText(`[${block.name}](${block.uri})`);
|
|
894
|
+
break;
|
|
895
|
+
case "resource": {
|
|
896
|
+
const res = block.resource;
|
|
897
|
+
if ("text" in res && typeof res.text === "string") {
|
|
898
|
+
pushText(`<resource uri="${res.uri}">\n${res.text}\n</resource>`);
|
|
899
|
+
}
|
|
900
|
+
else if ("blob" in res) {
|
|
901
|
+
pushText(`[binary resource](${res.uri})`);
|
|
902
|
+
}
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case "image": {
|
|
906
|
+
imageIndex += 1;
|
|
907
|
+
const imageUrl = toVisionNativeImageUrl(block);
|
|
908
|
+
if (imageUrl) {
|
|
909
|
+
content.push({ type: "image_url", image_url: { url: imageUrl } });
|
|
910
|
+
plainTextParts.push(`[image: ${block.mimeType}]`);
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
pushText(`<image_unsupported_format index="${imageIndex}" mime="${escapeAttribute(block.mimeType)}">Only image/jpeg, image/jpg, image/png inputs can be sent to the native vision model. Attach a supported HTTPS image URL or supported base64 image data.</image_unsupported_format>`);
|
|
914
|
+
}
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
case "audio":
|
|
918
|
+
pushText("[unsupported audio block]");
|
|
919
|
+
break;
|
|
920
|
+
default:
|
|
921
|
+
pushText(`[unknown block type ${block.type}]`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return { content, plainText: plainTextParts.join("\n") };
|
|
925
|
+
}
|
|
926
|
+
function toVisionNativeImageUrl(block) {
|
|
927
|
+
if (!isSupportedVisionMime(block.mimeType))
|
|
928
|
+
return null;
|
|
929
|
+
if (typeof block.uri === "string" && block.uri.length > 0) {
|
|
930
|
+
if (block.uri.startsWith("data:"))
|
|
931
|
+
return block.uri;
|
|
932
|
+
try {
|
|
933
|
+
const url = new URL(block.uri);
|
|
934
|
+
if (url.protocol === "https:")
|
|
935
|
+
return block.uri;
|
|
936
|
+
}
|
|
937
|
+
catch {
|
|
938
|
+
// Fall through to inline data when available.
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (typeof block.data === "string" && block.data.length > 0) {
|
|
942
|
+
return `data:${block.mimeType.toLowerCase()};base64,${block.data}`;
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
function isSupportedVisionMime(mimeType) {
|
|
947
|
+
switch (mimeType.toLowerCase()) {
|
|
948
|
+
case "image/jpeg":
|
|
949
|
+
case "image/jpg":
|
|
950
|
+
case "image/png":
|
|
951
|
+
return true;
|
|
952
|
+
default:
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function escapeAttribute(value) {
|
|
957
|
+
return value
|
|
958
|
+
.replaceAll("&", "&")
|
|
959
|
+
.replaceAll('"', """)
|
|
960
|
+
.replaceAll("<", "<")
|
|
961
|
+
.replaceAll(">", ">");
|
|
962
|
+
}
|
|
705
963
|
/** Flatten the `content` of a user message into a plain string for replay. */
|
|
706
964
|
function stringifyUserMessage(content) {
|
|
707
|
-
|
|
965
|
+
if (typeof content === "string")
|
|
966
|
+
return content;
|
|
967
|
+
if (!Array.isArray(content))
|
|
968
|
+
return "";
|
|
969
|
+
return content
|
|
970
|
+
.map((part) => {
|
|
971
|
+
if (typeof part === "object" &&
|
|
972
|
+
part !== null &&
|
|
973
|
+
part.type === "text" &&
|
|
974
|
+
typeof part.text === "string") {
|
|
975
|
+
return part.text;
|
|
976
|
+
}
|
|
977
|
+
if (typeof part === "object" &&
|
|
978
|
+
part !== null &&
|
|
979
|
+
part.type === "image_url") {
|
|
980
|
+
return "[image]";
|
|
981
|
+
}
|
|
982
|
+
return "";
|
|
983
|
+
})
|
|
984
|
+
.filter((text) => text.length > 0)
|
|
985
|
+
.join("\n");
|
|
708
986
|
}
|
|
709
987
|
/**
|
|
710
988
|
* Read an `AGENTS.md` (preferred) or `CLAUDE.md` from the session's cwd, returning
|
|
@@ -740,4 +1018,99 @@ async function safeSessionUpdate(connection, params) {
|
|
|
740
1018
|
// best-effort
|
|
741
1019
|
}
|
|
742
1020
|
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Heuristically estimate the number of tokens in a list of messages.
|
|
1023
|
+
* Uses a simple 4-character-per-token rule, which is a safe baseline for
|
|
1024
|
+
* English and code.
|
|
1025
|
+
*/
|
|
1026
|
+
function estimateTokens(messages) {
|
|
1027
|
+
let chars = 0;
|
|
1028
|
+
for (const m of messages) {
|
|
1029
|
+
if (typeof m.content === "string") {
|
|
1030
|
+
chars += m.content.length;
|
|
1031
|
+
}
|
|
1032
|
+
else if (Array.isArray(m.content)) {
|
|
1033
|
+
for (const part of m.content) {
|
|
1034
|
+
if ("text" in part && typeof part.text === "string") {
|
|
1035
|
+
chars += part.text.length;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (m.role === "assistant" && m.tool_calls) {
|
|
1040
|
+
for (const tc of m.tool_calls) {
|
|
1041
|
+
if ("function" in tc) {
|
|
1042
|
+
chars += tc.function.name.length;
|
|
1043
|
+
chars += tc.function.arguments.length;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return Math.ceil(chars / 4);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Prune message history to stay within a target token limit.
|
|
1052
|
+
*
|
|
1053
|
+
* Strategy:
|
|
1054
|
+
* 1. Always keep the System prompt (index 0).
|
|
1055
|
+
* 2. Always keep the last `preserveTurns` interaction groups (default 10) to
|
|
1056
|
+
* maintain conversation flow. An interaction group (turn) typically starts
|
|
1057
|
+
* with a user message followed by assistant and tool responses.
|
|
1058
|
+
* 3. Evict the largest remaining interaction groups until the total estimate
|
|
1059
|
+
* is below `targetTokens`.
|
|
1060
|
+
*/
|
|
1061
|
+
function compactMessages(messages, targetTokens, preserveTurns = 10) {
|
|
1062
|
+
if (messages.length <= 1)
|
|
1063
|
+
return messages;
|
|
1064
|
+
const systemPrompt = messages[0];
|
|
1065
|
+
const remaining = messages.slice(1);
|
|
1066
|
+
// Group messages into interaction turns. A turn starts with a "user" message.
|
|
1067
|
+
const turns = [];
|
|
1068
|
+
let currentTurn = [];
|
|
1069
|
+
for (const m of remaining) {
|
|
1070
|
+
if (m.role === "user" && currentTurn.length > 0) {
|
|
1071
|
+
turns.push(currentTurn);
|
|
1072
|
+
currentTurn = [];
|
|
1073
|
+
}
|
|
1074
|
+
currentTurn.push(m);
|
|
1075
|
+
}
|
|
1076
|
+
if (currentTurn.length > 0) {
|
|
1077
|
+
turns.push(currentTurn);
|
|
1078
|
+
}
|
|
1079
|
+
// If we have fewer turns than we want to preserve, just return the messages.
|
|
1080
|
+
if (turns.length <= preserveTurns)
|
|
1081
|
+
return messages;
|
|
1082
|
+
let currentEstimate = estimateTokens(messages);
|
|
1083
|
+
if (currentEstimate <= targetTokens)
|
|
1084
|
+
return messages;
|
|
1085
|
+
debug(`compactMessages: currentEstimate=${currentEstimate} target=${targetTokens} turns=${turns.length}`);
|
|
1086
|
+
// Identify candidates for eviction: all turns except the last `preserveTurns`.
|
|
1087
|
+
const tail = turns.slice(-preserveTurns);
|
|
1088
|
+
const candidateTurns = turns
|
|
1089
|
+
.slice(0, -preserveTurns)
|
|
1090
|
+
.map((turn, index) => ({
|
|
1091
|
+
turn,
|
|
1092
|
+
index,
|
|
1093
|
+
tokens: estimateTokens(turn),
|
|
1094
|
+
}));
|
|
1095
|
+
// Sort candidates by size (largest first).
|
|
1096
|
+
candidateTurns.sort((a, b) => b.tokens - a.tokens);
|
|
1097
|
+
const evictedIndices = new Set();
|
|
1098
|
+
for (const c of candidateTurns) {
|
|
1099
|
+
if (currentEstimate <= targetTokens)
|
|
1100
|
+
break;
|
|
1101
|
+
evictedIndices.add(c.index);
|
|
1102
|
+
currentEstimate -= c.tokens;
|
|
1103
|
+
}
|
|
1104
|
+
const compacted = [systemPrompt];
|
|
1105
|
+
for (let i = 0; i < turns.length - preserveTurns; i++) {
|
|
1106
|
+
if (!evictedIndices.has(i)) {
|
|
1107
|
+
compacted.push(...turns[i]);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
for (const turn of tail) {
|
|
1111
|
+
compacted.push(...turn);
|
|
1112
|
+
}
|
|
1113
|
+
debug(`compactMessages: done, newEstimate=${estimateTokens(compacted)} messageCount=${compacted.length}`);
|
|
1114
|
+
return compacted;
|
|
1115
|
+
}
|
|
743
1116
|
//# sourceMappingURL=agent.js.map
|