glm-acp-agent 1.1.2 → 1.1.4
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 +109 -55
- package/dist/llm/glm-client.d.ts +11 -0
- package/dist/llm/glm-client.d.ts.map +1 -1
- package/dist/llm/glm-client.js +32 -0
- package/dist/llm/glm-client.js.map +1 -1
- package/dist/protocol/agent.d.ts +10 -3
- package/dist/protocol/agent.d.ts.map +1 -1
- package/dist/protocol/agent.js +348 -71
- 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 +7 -1
- package/dist/protocol/session-store.d.ts.map +1 -1
- package/dist/protocol/session-store.js +16 -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 +5 -6
- package/dist/protocol/system-prompt.js.map +1 -1
- package/dist/tests/agent.test.js +247 -22
- package/dist/tests/agent.test.js.map +1 -1
- package/dist/tests/executor.test.js +217 -63
- package/dist/tests/executor.test.js.map +1 -1
- package/dist/tests/glm-client.test.js +23 -1
- package/dist/tests/glm-client.test.js.map +1 -1
- package/dist/tests/integration.test.js +71 -15
- package/dist/tests/integration.test.js.map +1 -1
- package/dist/tools/definitions.d.ts +3 -3
- package/dist/tools/definitions.js +7 -7
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/executor.d.ts +17 -8
- package/dist/tools/executor.d.ts.map +1 -1
- package/dist/tools/executor.js +153 -143
- 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 +7 -3
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, } 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";
|
|
@@ -74,8 +74,9 @@ export class GlmAcpAgent {
|
|
|
74
74
|
async initialize(params) {
|
|
75
75
|
// Negotiate the lowest version both sides support.
|
|
76
76
|
const negotiatedVersion = params.protocolVersion <= VERSION ? params.protocolVersion : VERSION;
|
|
77
|
-
//
|
|
78
|
-
//
|
|
77
|
+
// Keep client capabilities for compatibility with ACP initialize payloads.
|
|
78
|
+
// Built-in local tools run in this agent process and are not gated on
|
|
79
|
+
// client fs/terminal support.
|
|
79
80
|
this.clientCapabilities = params.clientCapabilities ?? null;
|
|
80
81
|
return {
|
|
81
82
|
protocolVersion: negotiatedVersion,
|
|
@@ -118,7 +119,7 @@ export class GlmAcpAgent {
|
|
|
118
119
|
promptCapabilities: {
|
|
119
120
|
// Baseline (text + resource_link) is implicit; we additionally accept
|
|
120
121
|
// embedded resources for inline file context, plus images for
|
|
121
|
-
//
|
|
122
|
+
// glm-5v-turbo native vision and the Vision MCP fallback path.
|
|
122
123
|
// Set ACP_GLM_PROMPT_IMAGES=false (or =0) to stop advertising image
|
|
123
124
|
// support so clients like Zed won't offer the image-attachment UI.
|
|
124
125
|
embeddedContext: true,
|
|
@@ -134,11 +135,12 @@ export class GlmAcpAgent {
|
|
|
134
135
|
},
|
|
135
136
|
};
|
|
136
137
|
}
|
|
137
|
-
async authenticate(
|
|
138
|
+
async authenticate(params) {
|
|
138
139
|
// Authentication is configured externally — either via Z_AI_API_KEY in the
|
|
139
140
|
// environment or via the credentials file written by `glm-acp-agent --setup`.
|
|
140
141
|
// The agent has nothing to do here; failures will surface when the model
|
|
141
142
|
// is first called.
|
|
143
|
+
void params;
|
|
142
144
|
return {};
|
|
143
145
|
}
|
|
144
146
|
async newSession(params) {
|
|
@@ -165,10 +167,12 @@ export class GlmAcpAgent {
|
|
|
165
167
|
model,
|
|
166
168
|
toolDefinitions,
|
|
167
169
|
mcpTools,
|
|
170
|
+
mode: "default",
|
|
168
171
|
});
|
|
169
172
|
return {
|
|
170
173
|
sessionId,
|
|
171
174
|
models: this.modelsState(model),
|
|
175
|
+
modes: this.modesState("default"),
|
|
172
176
|
};
|
|
173
177
|
}
|
|
174
178
|
async unstable_setSessionModel(params) {
|
|
@@ -203,9 +207,49 @@ export class GlmAcpAgent {
|
|
|
203
207
|
currentModelId,
|
|
204
208
|
};
|
|
205
209
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
/** Build the SessionModeState we advertise on session create/load/resume/fork. */
|
|
211
|
+
modesState(currentModeId) {
|
|
212
|
+
return {
|
|
213
|
+
availableModes: [
|
|
214
|
+
{
|
|
215
|
+
id: "default",
|
|
216
|
+
name: "Ask for permission",
|
|
217
|
+
description: "Prompt before edits and commands.",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: "accept_edits",
|
|
221
|
+
name: "Auto-approve edits",
|
|
222
|
+
description: "Edits run without prompting. Commands still prompt.",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "bypass_permissions",
|
|
226
|
+
name: "Bypass all permissions",
|
|
227
|
+
description: "Edits and commands run without prompting.",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
currentModeId,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async setSessionMode(params) {
|
|
234
|
+
const session = this.sessions.get(params.sessionId);
|
|
235
|
+
if (!session) {
|
|
236
|
+
throw new Error(`Session not found: ${params.sessionId}`);
|
|
237
|
+
}
|
|
238
|
+
const validModeIds = ["default", "accept_edits", "bypass_permissions"];
|
|
239
|
+
if (!validModeIds.includes(params.modeId)) {
|
|
240
|
+
throw new Error(`Invalid modeId: ${params.modeId}. Valid modes are: ${validModeIds.join(", ")}`);
|
|
241
|
+
}
|
|
242
|
+
const newMode = params.modeId;
|
|
243
|
+
session.mode = newMode;
|
|
244
|
+
session.updatedAt = new Date().toISOString();
|
|
245
|
+
this.persistSession(params.sessionId, session);
|
|
246
|
+
await safeSessionUpdate(this.connection, {
|
|
247
|
+
sessionId: params.sessionId,
|
|
248
|
+
update: {
|
|
249
|
+
sessionUpdate: "current_mode_update",
|
|
250
|
+
currentModeId: newMode,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
209
253
|
return {};
|
|
210
254
|
}
|
|
211
255
|
// ---------------------------------------------------------------------------
|
|
@@ -243,18 +287,21 @@ export class GlmAcpAgent {
|
|
|
243
287
|
else if (connSignal?.aborted) {
|
|
244
288
|
abortController.abort();
|
|
245
289
|
}
|
|
246
|
-
// Convert ACP content blocks into a GLM user message.
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// result is always a plain string for the chat-completions endpoint.
|
|
290
|
+
// Convert ACP content blocks into a GLM user message. Most models receive
|
|
291
|
+
// plain text, with images preprocessed through Vision MCP. Native vision
|
|
292
|
+
// models receive OpenAI-style multimodal content parts directly.
|
|
250
293
|
if (isDebugEnabled()) {
|
|
251
294
|
for (const line of buildPromptBlockDiagnosticLines(params.prompt)) {
|
|
252
295
|
debug(line);
|
|
253
296
|
}
|
|
254
297
|
}
|
|
255
|
-
|
|
256
|
-
preprocessed =
|
|
257
|
-
|
|
298
|
+
const visionNative = isVisionNativeModel(session.model);
|
|
299
|
+
const preprocessed = visionNative
|
|
300
|
+
? { blocks: params.prompt, cleanups: [] }
|
|
301
|
+
: await preprocessImageBlocks(params.prompt, this.visionClient, abortController.signal);
|
|
302
|
+
const { content: userContent, plainText: userText } = visionNative
|
|
303
|
+
? renderVisionNativePromptBlocks(preprocessed.blocks)
|
|
304
|
+
: renderPromptBlocks(preprocessed.blocks);
|
|
258
305
|
session.messages.push({ role: "user", content: userContent });
|
|
259
306
|
// Echo back the client-supplied messageId on every response (success,
|
|
260
307
|
// cancelled, or error) so the client can correlate the turn.
|
|
@@ -405,13 +452,17 @@ export class GlmAcpAgent {
|
|
|
405
452
|
model: persisted.model,
|
|
406
453
|
toolDefinitions,
|
|
407
454
|
mcpTools,
|
|
455
|
+
mode: persisted.mode,
|
|
408
456
|
};
|
|
409
457
|
this.sessions.set(params.sessionId, restored);
|
|
410
458
|
// Replay user/assistant text turns so the client can rehydrate its UI.
|
|
411
459
|
// Tool / system messages are skipped — they're internal and the client
|
|
412
460
|
// doesn't render them on its own.
|
|
413
461
|
await this.replayMessages(params.sessionId, persisted.messages);
|
|
414
|
-
return {
|
|
462
|
+
return {
|
|
463
|
+
models: this.modelsState(persisted.model),
|
|
464
|
+
modes: this.modesState(persisted.mode),
|
|
465
|
+
};
|
|
415
466
|
}
|
|
416
467
|
async unstable_forkSession(params) {
|
|
417
468
|
const source = this.sessions.get(params.sessionId);
|
|
@@ -433,12 +484,14 @@ export class GlmAcpAgent {
|
|
|
433
484
|
model: persisted.model,
|
|
434
485
|
toolDefinitions,
|
|
435
486
|
mcpTools,
|
|
487
|
+
mode: persisted.mode,
|
|
436
488
|
};
|
|
437
489
|
this.sessions.set(newSessionId, forked);
|
|
438
490
|
this.persistSession(newSessionId, forked);
|
|
439
491
|
return {
|
|
440
492
|
sessionId: newSessionId,
|
|
441
493
|
models: this.modelsState(forked.model),
|
|
494
|
+
modes: this.modesState(forked.mode),
|
|
442
495
|
};
|
|
443
496
|
}
|
|
444
497
|
async resumeSession(params) {
|
|
@@ -456,11 +509,15 @@ export class GlmAcpAgent {
|
|
|
456
509
|
model: persisted.model,
|
|
457
510
|
toolDefinitions,
|
|
458
511
|
mcpTools,
|
|
512
|
+
mode: persisted.mode,
|
|
459
513
|
};
|
|
460
514
|
this.sessions.set(params.sessionId, restored);
|
|
461
515
|
// Resume does NOT replay history — the client keeps its own UI state and
|
|
462
516
|
// just wants the agent to pick up where it left off.
|
|
463
|
-
return {
|
|
517
|
+
return {
|
|
518
|
+
models: this.modelsState(persisted.model),
|
|
519
|
+
modes: this.modesState(persisted.mode),
|
|
520
|
+
};
|
|
464
521
|
}
|
|
465
522
|
// ---------------------------------------------------------------------------
|
|
466
523
|
// Persistence helpers
|
|
@@ -473,6 +530,7 @@ export class GlmAcpAgent {
|
|
|
473
530
|
title: session.title,
|
|
474
531
|
updatedAt: session.updatedAt,
|
|
475
532
|
model: session.model,
|
|
533
|
+
mode: session.mode,
|
|
476
534
|
};
|
|
477
535
|
}
|
|
478
536
|
persistSession(sessionId, session) {
|
|
@@ -545,53 +603,89 @@ export class GlmAcpAgent {
|
|
|
545
603
|
* Returns the ACP stop reason and optional token usage reported by the model.
|
|
546
604
|
*/
|
|
547
605
|
async runPromptLoop(sessionId, session, signal) {
|
|
548
|
-
const executor = new ToolExecutor(this.connection, sessionId, this.clientCapabilities, signal, this.visionClient, session.mcpTools
|
|
606
|
+
const executor = new ToolExecutor(this.connection, sessionId, this.clientCapabilities, signal, this.visionClient, session.mcpTools, session.cwd,
|
|
607
|
+
// Use a thunk so mode changes mid-turn take effect on the next tool call.
|
|
608
|
+
() => this.sessions.get(sessionId)?.mode ?? "default");
|
|
549
609
|
let lastUsage;
|
|
610
|
+
let overflowRetryCount = 0;
|
|
550
611
|
for (let turn = 0; turn < this.maxTurns; turn++) {
|
|
551
612
|
if (signal.aborted)
|
|
552
613
|
return { stopReason: "cancelled" };
|
|
614
|
+
// Proactive compaction: check if history exceeds 90% of context window.
|
|
615
|
+
const window = getContextWindow(session.model);
|
|
616
|
+
const limit = Math.floor(window * 0.9);
|
|
617
|
+
if (estimateTokens(session.messages) > limit) {
|
|
618
|
+
session.messages = compactMessages(session.messages, Math.floor(window * 0.8));
|
|
619
|
+
}
|
|
553
620
|
debug(`promptLoop: turn=${turn} session=${sessionId} model=${session.model} messages=${session.messages.length}`);
|
|
554
621
|
const toolCalls = [];
|
|
555
622
|
let assistantText = "";
|
|
556
623
|
let lastStopReason;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
624
|
+
let retryTurn = false;
|
|
625
|
+
try {
|
|
626
|
+
// Stream the GLM response. The session's currently-selected model wins;
|
|
627
|
+
// it's mutated by `unstable_setSessionModel` between turns.
|
|
628
|
+
for await (const chunk of this.glm.streamChat(session.messages, signal, {
|
|
629
|
+
model: session.model,
|
|
630
|
+
tools: session.toolDefinitions,
|
|
631
|
+
})) {
|
|
632
|
+
if (signal.aborted)
|
|
633
|
+
return { stopReason: "cancelled" };
|
|
634
|
+
if (chunk.thinking) {
|
|
635
|
+
await this.connection.sessionUpdate({
|
|
636
|
+
sessionId,
|
|
637
|
+
update: {
|
|
638
|
+
sessionUpdate: "agent_thought_chunk",
|
|
639
|
+
content: { type: "text", text: chunk.thinking },
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
if (chunk.text) {
|
|
644
|
+
assistantText += chunk.text;
|
|
645
|
+
await this.connection.sessionUpdate({
|
|
646
|
+
sessionId,
|
|
647
|
+
update: {
|
|
648
|
+
sessionUpdate: "agent_message_chunk",
|
|
649
|
+
content: { type: "text", text: chunk.text },
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (chunk.toolCall) {
|
|
654
|
+
debug(`promptLoop: toolCall id=${chunk.toolCall.id} name=${chunk.toolCall.name}`);
|
|
655
|
+
toolCalls.push(chunk.toolCall);
|
|
656
|
+
}
|
|
657
|
+
if (chunk.usage) {
|
|
658
|
+
lastUsage = chunk.usage;
|
|
659
|
+
}
|
|
660
|
+
if (chunk.done) {
|
|
661
|
+
lastStopReason = chunk.stopReason;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
563
666
|
if (signal.aborted)
|
|
564
667
|
return { stopReason: "cancelled" };
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
668
|
+
const body = err?.error;
|
|
669
|
+
const isOverflow = body?.code === ERR_CONTEXT_OVERFLOW ||
|
|
670
|
+
body?.code === String(ERR_CONTEXT_OVERFLOW);
|
|
671
|
+
if (isOverflow && overflowRetryCount < 1) {
|
|
672
|
+
debug(`promptLoop: context overflow (1261) detected, performing emergency compaction`);
|
|
673
|
+
const window = getContextWindow(session.model);
|
|
674
|
+
session.messages = compactMessages(session.messages, Math.floor(window * 0.7));
|
|
675
|
+
overflowRetryCount++;
|
|
676
|
+
retryTurn = true;
|
|
573
677
|
}
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
await this.connection.sessionUpdate({
|
|
577
|
-
sessionId,
|
|
578
|
-
update: {
|
|
579
|
-
sessionUpdate: "agent_message_chunk",
|
|
580
|
-
content: { type: "text", text: chunk.text },
|
|
581
|
-
},
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
if (chunk.toolCall) {
|
|
585
|
-
debug(`promptLoop: toolCall id=${chunk.toolCall.id} name=${chunk.toolCall.name}`);
|
|
586
|
-
toolCalls.push(chunk.toolCall);
|
|
587
|
-
}
|
|
588
|
-
if (chunk.usage) {
|
|
589
|
-
lastUsage = chunk.usage;
|
|
678
|
+
else if (isOverflow) {
|
|
679
|
+
throw new Error("Context overflow persisted after emergency compaction", { cause: err });
|
|
590
680
|
}
|
|
591
|
-
|
|
592
|
-
|
|
681
|
+
else {
|
|
682
|
+
throw err;
|
|
593
683
|
}
|
|
594
684
|
}
|
|
685
|
+
if (retryTurn) {
|
|
686
|
+
turn--; // Re-run the same turn index
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
595
689
|
// Record the assistant turn in history so the model has full context for
|
|
596
690
|
// the next iteration.
|
|
597
691
|
if (toolCalls.length > 0) {
|
|
@@ -646,27 +740,9 @@ export class GlmAcpAgent {
|
|
|
646
740
|
return "end_turn";
|
|
647
741
|
}
|
|
648
742
|
}
|
|
649
|
-
/** Tool schemas we expose
|
|
743
|
+
/** Tool schemas we expose for agent-owned local tools plus session MCP tools. */
|
|
650
744
|
availableToolDefinitions(mcpTools = null) {
|
|
651
|
-
|
|
652
|
-
// or it omitted the field), advertise the full set so the system prompt
|
|
653
|
-
// still mentions every tool. The executor will surface a clean error if
|
|
654
|
-
// the model invokes one whose capability is missing.
|
|
655
|
-
if (!this.clientCapabilities) {
|
|
656
|
-
return [...TOOL_DEFINITIONS, ...(mcpTools?.toolDefinitions ?? [])];
|
|
657
|
-
}
|
|
658
|
-
const fs = this.clientCapabilities.fs ?? {};
|
|
659
|
-
const terminal = this.clientCapabilities.terminal ?? false;
|
|
660
|
-
const names = [];
|
|
661
|
-
if (fs.readTextFile)
|
|
662
|
-
names.push("read_file");
|
|
663
|
-
if (fs.writeTextFile)
|
|
664
|
-
names.push("write_file");
|
|
665
|
-
if (terminal)
|
|
666
|
-
names.push("list_files", "run_command");
|
|
667
|
-
// Web tools and image_analysis run inside the agent process, so they are
|
|
668
|
-
// unconditional except when vision is explicitly disabled.
|
|
669
|
-
names.push("web_search", "web_reader");
|
|
745
|
+
const names = ["read_file", "write_file", "list_files", "run_command", "web_search", "web_reader"];
|
|
670
746
|
if (this.visionClientExplicit ? this._visionClient !== null : true) {
|
|
671
747
|
names.push("image_analysis");
|
|
672
748
|
}
|
|
@@ -719,9 +795,115 @@ function renderPromptBlocks(blocks) {
|
|
|
719
795
|
const plainText = textParts.join("\n");
|
|
720
796
|
return { content: plainText, plainText };
|
|
721
797
|
}
|
|
798
|
+
function renderVisionNativePromptBlocks(blocks) {
|
|
799
|
+
const content = [];
|
|
800
|
+
const plainTextParts = [];
|
|
801
|
+
let imageIndex = 0;
|
|
802
|
+
const pushText = (text) => {
|
|
803
|
+
if (text.length === 0)
|
|
804
|
+
return;
|
|
805
|
+
content.push({ type: "text", text });
|
|
806
|
+
plainTextParts.push(text);
|
|
807
|
+
};
|
|
808
|
+
for (const block of blocks) {
|
|
809
|
+
switch (block.type) {
|
|
810
|
+
case "text":
|
|
811
|
+
pushText(block.text);
|
|
812
|
+
break;
|
|
813
|
+
case "resource_link":
|
|
814
|
+
pushText(`[${block.name}](${block.uri})`);
|
|
815
|
+
break;
|
|
816
|
+
case "resource": {
|
|
817
|
+
const res = block.resource;
|
|
818
|
+
if ("text" in res && typeof res.text === "string") {
|
|
819
|
+
pushText(`<resource uri="${res.uri}">\n${res.text}\n</resource>`);
|
|
820
|
+
}
|
|
821
|
+
else if ("blob" in res) {
|
|
822
|
+
pushText(`[binary resource](${res.uri})`);
|
|
823
|
+
}
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
case "image": {
|
|
827
|
+
imageIndex += 1;
|
|
828
|
+
const imageUrl = toVisionNativeImageUrl(block);
|
|
829
|
+
if (imageUrl) {
|
|
830
|
+
content.push({ type: "image_url", image_url: { url: imageUrl } });
|
|
831
|
+
plainTextParts.push(`[image: ${block.mimeType}]`);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
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>`);
|
|
835
|
+
}
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
case "audio":
|
|
839
|
+
pushText("[unsupported audio block]");
|
|
840
|
+
break;
|
|
841
|
+
default:
|
|
842
|
+
pushText(`[unknown block type ${block.type}]`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { content, plainText: plainTextParts.join("\n") };
|
|
846
|
+
}
|
|
847
|
+
function toVisionNativeImageUrl(block) {
|
|
848
|
+
if (!isSupportedVisionMime(block.mimeType))
|
|
849
|
+
return null;
|
|
850
|
+
if (typeof block.uri === "string" && block.uri.length > 0) {
|
|
851
|
+
if (block.uri.startsWith("data:"))
|
|
852
|
+
return block.uri;
|
|
853
|
+
try {
|
|
854
|
+
const url = new URL(block.uri);
|
|
855
|
+
if (url.protocol === "https:")
|
|
856
|
+
return block.uri;
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Fall through to inline data when available.
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (typeof block.data === "string" && block.data.length > 0) {
|
|
863
|
+
return `data:${block.mimeType.toLowerCase()};base64,${block.data}`;
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
function isSupportedVisionMime(mimeType) {
|
|
868
|
+
switch (mimeType.toLowerCase()) {
|
|
869
|
+
case "image/jpeg":
|
|
870
|
+
case "image/jpg":
|
|
871
|
+
case "image/png":
|
|
872
|
+
return true;
|
|
873
|
+
default:
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
function escapeAttribute(value) {
|
|
878
|
+
return value
|
|
879
|
+
.replaceAll("&", "&")
|
|
880
|
+
.replaceAll('"', """)
|
|
881
|
+
.replaceAll("<", "<")
|
|
882
|
+
.replaceAll(">", ">");
|
|
883
|
+
}
|
|
722
884
|
/** Flatten the `content` of a user message into a plain string for replay. */
|
|
723
885
|
function stringifyUserMessage(content) {
|
|
724
|
-
|
|
886
|
+
if (typeof content === "string")
|
|
887
|
+
return content;
|
|
888
|
+
if (!Array.isArray(content))
|
|
889
|
+
return "";
|
|
890
|
+
return content
|
|
891
|
+
.map((part) => {
|
|
892
|
+
if (typeof part === "object" &&
|
|
893
|
+
part !== null &&
|
|
894
|
+
part.type === "text" &&
|
|
895
|
+
typeof part.text === "string") {
|
|
896
|
+
return part.text;
|
|
897
|
+
}
|
|
898
|
+
if (typeof part === "object" &&
|
|
899
|
+
part !== null &&
|
|
900
|
+
part.type === "image_url") {
|
|
901
|
+
return "[image]";
|
|
902
|
+
}
|
|
903
|
+
return "";
|
|
904
|
+
})
|
|
905
|
+
.filter((text) => text.length > 0)
|
|
906
|
+
.join("\n");
|
|
725
907
|
}
|
|
726
908
|
/**
|
|
727
909
|
* Read an `AGENTS.md` (preferred) or `CLAUDE.md` from the session's cwd, returning
|
|
@@ -757,4 +939,99 @@ async function safeSessionUpdate(connection, params) {
|
|
|
757
939
|
// best-effort
|
|
758
940
|
}
|
|
759
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* Heuristically estimate the number of tokens in a list of messages.
|
|
944
|
+
* Uses a simple 4-character-per-token rule, which is a safe baseline for
|
|
945
|
+
* English and code.
|
|
946
|
+
*/
|
|
947
|
+
function estimateTokens(messages) {
|
|
948
|
+
let chars = 0;
|
|
949
|
+
for (const m of messages) {
|
|
950
|
+
if (typeof m.content === "string") {
|
|
951
|
+
chars += m.content.length;
|
|
952
|
+
}
|
|
953
|
+
else if (Array.isArray(m.content)) {
|
|
954
|
+
for (const part of m.content) {
|
|
955
|
+
if ("text" in part && typeof part.text === "string") {
|
|
956
|
+
chars += part.text.length;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (m.role === "assistant" && m.tool_calls) {
|
|
961
|
+
for (const tc of m.tool_calls) {
|
|
962
|
+
if ("function" in tc) {
|
|
963
|
+
chars += tc.function.name.length;
|
|
964
|
+
chars += tc.function.arguments.length;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return Math.ceil(chars / 4);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Prune message history to stay within a target token limit.
|
|
973
|
+
*
|
|
974
|
+
* Strategy:
|
|
975
|
+
* 1. Always keep the System prompt (index 0).
|
|
976
|
+
* 2. Always keep the last `preserveTurns` interaction groups (default 10) to
|
|
977
|
+
* maintain conversation flow. An interaction group (turn) typically starts
|
|
978
|
+
* with a user message followed by assistant and tool responses.
|
|
979
|
+
* 3. Evict the largest remaining interaction groups until the total estimate
|
|
980
|
+
* is below `targetTokens`.
|
|
981
|
+
*/
|
|
982
|
+
function compactMessages(messages, targetTokens, preserveTurns = 10) {
|
|
983
|
+
if (messages.length <= 1)
|
|
984
|
+
return messages;
|
|
985
|
+
const systemPrompt = messages[0];
|
|
986
|
+
const remaining = messages.slice(1);
|
|
987
|
+
// Group messages into interaction turns. A turn starts with a "user" message.
|
|
988
|
+
const turns = [];
|
|
989
|
+
let currentTurn = [];
|
|
990
|
+
for (const m of remaining) {
|
|
991
|
+
if (m.role === "user" && currentTurn.length > 0) {
|
|
992
|
+
turns.push(currentTurn);
|
|
993
|
+
currentTurn = [];
|
|
994
|
+
}
|
|
995
|
+
currentTurn.push(m);
|
|
996
|
+
}
|
|
997
|
+
if (currentTurn.length > 0) {
|
|
998
|
+
turns.push(currentTurn);
|
|
999
|
+
}
|
|
1000
|
+
// If we have fewer turns than we want to preserve, just return the messages.
|
|
1001
|
+
if (turns.length <= preserveTurns)
|
|
1002
|
+
return messages;
|
|
1003
|
+
let currentEstimate = estimateTokens(messages);
|
|
1004
|
+
if (currentEstimate <= targetTokens)
|
|
1005
|
+
return messages;
|
|
1006
|
+
debug(`compactMessages: currentEstimate=${currentEstimate} target=${targetTokens} turns=${turns.length}`);
|
|
1007
|
+
// Identify candidates for eviction: all turns except the last `preserveTurns`.
|
|
1008
|
+
const tail = turns.slice(-preserveTurns);
|
|
1009
|
+
const candidateTurns = turns
|
|
1010
|
+
.slice(0, -preserveTurns)
|
|
1011
|
+
.map((turn, index) => ({
|
|
1012
|
+
turn,
|
|
1013
|
+
index,
|
|
1014
|
+
tokens: estimateTokens(turn),
|
|
1015
|
+
}));
|
|
1016
|
+
// Sort candidates by size (largest first).
|
|
1017
|
+
candidateTurns.sort((a, b) => b.tokens - a.tokens);
|
|
1018
|
+
const evictedIndices = new Set();
|
|
1019
|
+
for (const c of candidateTurns) {
|
|
1020
|
+
if (currentEstimate <= targetTokens)
|
|
1021
|
+
break;
|
|
1022
|
+
evictedIndices.add(c.index);
|
|
1023
|
+
currentEstimate -= c.tokens;
|
|
1024
|
+
}
|
|
1025
|
+
const compacted = [systemPrompt];
|
|
1026
|
+
for (let i = 0; i < turns.length - preserveTurns; i++) {
|
|
1027
|
+
if (!evictedIndices.has(i)) {
|
|
1028
|
+
compacted.push(...turns[i]);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
for (const turn of tail) {
|
|
1032
|
+
compacted.push(...turn);
|
|
1033
|
+
}
|
|
1034
|
+
debug(`compactMessages: done, newEstimate=${estimateTokens(compacted)} messageCount=${compacted.length}`);
|
|
1035
|
+
return compacted;
|
|
1036
|
+
}
|
|
760
1037
|
//# sourceMappingURL=agent.js.map
|