glm-acp-agent 1.0.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 +365 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/credentials.d.ts +15 -0
- package/dist/llm/credentials.d.ts.map +1 -0
- package/dist/llm/credentials.js +54 -0
- package/dist/llm/credentials.js.map +1 -0
- package/dist/llm/glm-client.d.ts +63 -0
- package/dist/llm/glm-client.d.ts.map +1 -0
- package/dist/llm/glm-client.js +217 -0
- package/dist/llm/glm-client.js.map +1 -0
- package/dist/llm/logger.d.ts +19 -0
- package/dist/llm/logger.d.ts.map +1 -0
- package/dist/llm/logger.js +44 -0
- package/dist/llm/logger.js.map +1 -0
- package/dist/protocol/agent.d.ts +77 -0
- package/dist/protocol/agent.d.ts.map +1 -0
- package/dist/protocol/agent.js +732 -0
- package/dist/protocol/agent.js.map +1 -0
- package/dist/protocol/connection.d.ts +9 -0
- package/dist/protocol/connection.d.ts.map +1 -0
- package/dist/protocol/connection.js +18 -0
- package/dist/protocol/connection.js.map +1 -0
- package/dist/protocol/image-preprocessor.d.ts +32 -0
- package/dist/protocol/image-preprocessor.d.ts.map +1 -0
- package/dist/protocol/image-preprocessor.js +149 -0
- package/dist/protocol/image-preprocessor.js.map +1 -0
- package/dist/protocol/session-store.d.ts +54 -0
- package/dist/protocol/session-store.d.ts.map +1 -0
- package/dist/protocol/session-store.js +109 -0
- package/dist/protocol/session-store.js.map +1 -0
- package/dist/protocol/system-prompt.d.ts +29 -0
- package/dist/protocol/system-prompt.d.ts.map +1 -0
- package/dist/protocol/system-prompt.js +103 -0
- package/dist/protocol/system-prompt.js.map +1 -0
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +95 -0
- package/dist/setup.js.map +1 -0
- package/dist/tests/agent.test.d.ts +2 -0
- package/dist/tests/agent.test.d.ts.map +1 -0
- package/dist/tests/agent.test.js +1118 -0
- package/dist/tests/agent.test.js.map +1 -0
- package/dist/tests/credentials.test.d.ts +2 -0
- package/dist/tests/credentials.test.d.ts.map +1 -0
- package/dist/tests/credentials.test.js +112 -0
- package/dist/tests/credentials.test.js.map +1 -0
- package/dist/tests/executor.test.d.ts +2 -0
- package/dist/tests/executor.test.d.ts.map +1 -0
- package/dist/tests/executor.test.js +497 -0
- package/dist/tests/executor.test.js.map +1 -0
- package/dist/tests/glm-client.test.d.ts +2 -0
- package/dist/tests/glm-client.test.d.ts.map +1 -0
- package/dist/tests/glm-client.test.js +205 -0
- package/dist/tests/glm-client.test.js.map +1 -0
- package/dist/tests/image-preprocessor.test.d.ts +2 -0
- package/dist/tests/image-preprocessor.test.d.ts.map +1 -0
- package/dist/tests/image-preprocessor.test.js +133 -0
- package/dist/tests/image-preprocessor.test.js.map +1 -0
- package/dist/tests/integration.test.d.ts +2 -0
- package/dist/tests/integration.test.d.ts.map +1 -0
- package/dist/tests/integration.test.js +192 -0
- package/dist/tests/integration.test.js.map +1 -0
- package/dist/tests/logger.test.d.ts +2 -0
- package/dist/tests/logger.test.d.ts.map +1 -0
- package/dist/tests/logger.test.js +14 -0
- package/dist/tests/logger.test.js.map +1 -0
- package/dist/tests/mcp-arg-remap.test.d.ts +2 -0
- package/dist/tests/mcp-arg-remap.test.d.ts.map +1 -0
- package/dist/tests/mcp-arg-remap.test.js +65 -0
- package/dist/tests/mcp-arg-remap.test.js.map +1 -0
- package/dist/tests/vision-mcp-client.test.d.ts +2 -0
- package/dist/tests/vision-mcp-client.test.d.ts.map +1 -0
- package/dist/tests/vision-mcp-client.test.js +129 -0
- package/dist/tests/vision-mcp-client.test.js.map +1 -0
- package/dist/tests/zai-mcp-client.test.d.ts +2 -0
- package/dist/tests/zai-mcp-client.test.d.ts.map +1 -0
- package/dist/tests/zai-mcp-client.test.js +386 -0
- package/dist/tests/zai-mcp-client.test.js.map +1 -0
- package/dist/tools/definitions.d.ts +18 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +149 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/executor.d.ts +48 -0
- package/dist/tools/executor.d.ts.map +1 -0
- package/dist/tools/executor.js +670 -0
- package/dist/tools/executor.js.map +1 -0
- package/dist/tools/mcp-arg-remap.d.ts +21 -0
- package/dist/tools/mcp-arg-remap.d.ts.map +1 -0
- package/dist/tools/mcp-arg-remap.js +70 -0
- package/dist/tools/mcp-arg-remap.js.map +1 -0
- package/dist/tools/vision-mcp-client.d.ts +38 -0
- package/dist/tools/vision-mcp-client.d.ts.map +1 -0
- package/dist/tools/vision-mcp-client.js +191 -0
- package/dist/tools/vision-mcp-client.js.map +1 -0
- package/dist/tools/zai-mcp-client.d.ts +24 -0
- package/dist/tools/zai-mcp-client.d.ts.map +1 -0
- package/dist/tools/zai-mcp-client.js +189 -0
- package/dist/tools/zai-mcp-client.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join as pathJoin } from "node:path";
|
|
4
|
+
import { PROTOCOL_VERSION as VERSION } from "@agentclientprotocol/sdk";
|
|
5
|
+
import { GlmClient, getAvailableModels, getDefaultModel, } from "../llm/glm-client.js";
|
|
6
|
+
import { ToolExecutor } from "../tools/executor.js";
|
|
7
|
+
import { TOOL_DEFINITIONS } from "../tools/definitions.js";
|
|
8
|
+
import { SessionStore } from "./session-store.js";
|
|
9
|
+
import { buildSystemPrompt } from "./system-prompt.js";
|
|
10
|
+
import { preprocessImageBlocks, buildPromptBlockDiagnosticLines } from "./image-preprocessor.js";
|
|
11
|
+
import { StdioVisionMcpClient } from "../tools/vision-mcp-client.js";
|
|
12
|
+
import { resolveApiKey } from "../llm/credentials.js";
|
|
13
|
+
import { debug, error, isDebugEnabled } from "../llm/logger.js";
|
|
14
|
+
/**
|
|
15
|
+
* Maximum bytes of AGENTS.md / CLAUDE.md to embed in the system prompt.
|
|
16
|
+
* Caps the input-token cost of a single project's context. Z.AI prompt
|
|
17
|
+
* caching will absorb repeated reads across turns of the same session, but
|
|
18
|
+
* this still bounds the worst case for projects that ship enormous spec
|
|
19
|
+
* files in their AGENTS.md.
|
|
20
|
+
*/
|
|
21
|
+
const PROJECT_CONTEXT_CAP_CHARS = 8 * 1024;
|
|
22
|
+
/**
|
|
23
|
+
* GlmAcpAgent implements the ACP `Agent` interface.
|
|
24
|
+
*
|
|
25
|
+
* It bridges the ACP protocol (via `AgentSideConnection`) and the Zhipu AI
|
|
26
|
+
* GLM series models (via `GlmClient`), providing a full prompt loop with
|
|
27
|
+
* tool-calling and streaming support.
|
|
28
|
+
*/
|
|
29
|
+
export class GlmAcpAgent {
|
|
30
|
+
connection;
|
|
31
|
+
sessions = new Map();
|
|
32
|
+
_glm;
|
|
33
|
+
maxTurns;
|
|
34
|
+
clientCapabilities = null;
|
|
35
|
+
sessionStore;
|
|
36
|
+
_visionClient;
|
|
37
|
+
visionClientExplicit;
|
|
38
|
+
constructor(connection, options = {}) {
|
|
39
|
+
this.connection = connection;
|
|
40
|
+
this._glm = options.glm ?? null;
|
|
41
|
+
const candidateMaxTurns = options.maxTurns ?? 20;
|
|
42
|
+
this.maxTurns =
|
|
43
|
+
Number.isFinite(candidateMaxTurns) && candidateMaxTurns > 0
|
|
44
|
+
? Math.floor(candidateMaxTurns)
|
|
45
|
+
: 20;
|
|
46
|
+
this.sessionStore =
|
|
47
|
+
options.sessionStore === null
|
|
48
|
+
? null
|
|
49
|
+
: (options.sessionStore ?? new SessionStore());
|
|
50
|
+
this.visionClientExplicit = "visionClient" in options;
|
|
51
|
+
this._visionClient = options.visionClient ?? null;
|
|
52
|
+
}
|
|
53
|
+
get glm() {
|
|
54
|
+
if (this._glm === null) {
|
|
55
|
+
this._glm = new GlmClient();
|
|
56
|
+
}
|
|
57
|
+
return this._glm;
|
|
58
|
+
}
|
|
59
|
+
get visionClient() {
|
|
60
|
+
if (this.visionClientExplicit)
|
|
61
|
+
return this._visionClient;
|
|
62
|
+
if (!this._visionClient) {
|
|
63
|
+
const apiKey = resolveApiKey();
|
|
64
|
+
if (!apiKey)
|
|
65
|
+
return null;
|
|
66
|
+
this._visionClient = new StdioVisionMcpClient({ apiKey });
|
|
67
|
+
}
|
|
68
|
+
return this._visionClient;
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// ACP Lifecycle
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
async initialize(params) {
|
|
74
|
+
// Negotiate the lowest version both sides support.
|
|
75
|
+
const negotiatedVersion = params.protocolVersion <= VERSION ? params.protocolVersion : VERSION;
|
|
76
|
+
// Track client capabilities so we can adapt tool calls to what the client
|
|
77
|
+
// actually supports.
|
|
78
|
+
this.clientCapabilities = params.clientCapabilities ?? null;
|
|
79
|
+
return {
|
|
80
|
+
protocolVersion: negotiatedVersion,
|
|
81
|
+
agentInfo: {
|
|
82
|
+
name: "glm-acp-agent",
|
|
83
|
+
version: "1.0.0",
|
|
84
|
+
},
|
|
85
|
+
// Advertise auth methods so the ACP registry verifier and capable
|
|
86
|
+
// clients can discover how to configure us. The `agent`-default method
|
|
87
|
+
// (no `type` discriminator) signals that the agent reads its credentials
|
|
88
|
+
// itself at startup; the experimental `env_var` method gives clients
|
|
89
|
+
// that support it the metadata to prompt the user for the right var.
|
|
90
|
+
authMethods: [
|
|
91
|
+
{
|
|
92
|
+
id: "z-ai-api-key",
|
|
93
|
+
name: "Z.AI API key",
|
|
94
|
+
description: "Set Z_AI_API_KEY in the environment, or run `glm-acp-agent --setup` once to store the key on disk. Generate one at https://z.ai/manage-apikey/apikey-list",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: "env_var",
|
|
98
|
+
id: "z_ai_api_key",
|
|
99
|
+
name: "Z.AI API key",
|
|
100
|
+
description: "API key for the Z.AI / Zhipu AI service. Generate one at https://z.ai/manage-apikey/apikey-list",
|
|
101
|
+
link: "https://z.ai/manage-apikey/apikey-list",
|
|
102
|
+
vars: [
|
|
103
|
+
{
|
|
104
|
+
name: "Z_AI_API_KEY",
|
|
105
|
+
label: "Z.AI API key",
|
|
106
|
+
secret: true,
|
|
107
|
+
optional: false,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
agentCapabilities: {
|
|
113
|
+
loadSession: true,
|
|
114
|
+
promptCapabilities: {
|
|
115
|
+
// Baseline (text + resource_link) is implicit; we additionally accept
|
|
116
|
+
// embedded resources for inline file context, plus images for
|
|
117
|
+
// vision-capable GLM models (e.g. glm-4v-plus).
|
|
118
|
+
// Set ACP_GLM_PROMPT_IMAGES=false (or =0) to stop advertising image
|
|
119
|
+
// support so clients like Zed won't offer the image-attachment UI.
|
|
120
|
+
embeddedContext: true,
|
|
121
|
+
image: process.env["ACP_GLM_PROMPT_IMAGES"] !== "false" &&
|
|
122
|
+
process.env["ACP_GLM_PROMPT_IMAGES"] !== "0",
|
|
123
|
+
},
|
|
124
|
+
sessionCapabilities: {
|
|
125
|
+
close: {},
|
|
126
|
+
list: {},
|
|
127
|
+
fork: {},
|
|
128
|
+
resume: {},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async authenticate(_params) {
|
|
134
|
+
// Authentication is configured externally — either via Z_AI_API_KEY in the
|
|
135
|
+
// environment or via the credentials file written by `glm-acp-agent --setup`.
|
|
136
|
+
// The agent has nothing to do here; failures will surface when the model
|
|
137
|
+
// is first called.
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
async newSession(params) {
|
|
141
|
+
const sessionId = randomUUID();
|
|
142
|
+
debug(`newSession: id=${sessionId} cwd=${params.cwd} model=${getDefaultModel()}`);
|
|
143
|
+
const systemPrompt = {
|
|
144
|
+
role: "system",
|
|
145
|
+
content: buildSystemPrompt({
|
|
146
|
+
cwd: params.cwd,
|
|
147
|
+
tools: this.availableToolNames(),
|
|
148
|
+
agentsMd: loadProjectContext(params.cwd),
|
|
149
|
+
}),
|
|
150
|
+
};
|
|
151
|
+
const model = getDefaultModel();
|
|
152
|
+
this.sessions.set(sessionId, {
|
|
153
|
+
cwd: params.cwd,
|
|
154
|
+
messages: [systemPrompt],
|
|
155
|
+
abortController: null,
|
|
156
|
+
promptPromise: null,
|
|
157
|
+
title: null,
|
|
158
|
+
updatedAt: new Date().toISOString(),
|
|
159
|
+
model,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
sessionId,
|
|
163
|
+
models: this.modelsState(model),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async unstable_setSessionModel(params) {
|
|
167
|
+
const session = this.sessions.get(params.sessionId);
|
|
168
|
+
if (!session) {
|
|
169
|
+
throw new Error(`Session not found: ${params.sessionId}`);
|
|
170
|
+
}
|
|
171
|
+
const available = getAvailableModels();
|
|
172
|
+
const known = available.find((m) => m.modelId === params.modelId);
|
|
173
|
+
if (!known) {
|
|
174
|
+
// Allow the caller to pick any model id even if not in the curated list
|
|
175
|
+
// (Z.AI may offer models we haven't catalogued), but log a stderr hint.
|
|
176
|
+
process.stderr.write(`[glm-acp-agent] warning: model "${params.modelId}" is not in the advertised list; using as-is.\n`);
|
|
177
|
+
}
|
|
178
|
+
session.model = params.modelId;
|
|
179
|
+
session.updatedAt = new Date().toISOString();
|
|
180
|
+
// Notify clients so any UI that displays the active model refreshes
|
|
181
|
+
// immediately, instead of waiting for the next prompt to complete.
|
|
182
|
+
await safeSessionUpdate(this.connection, {
|
|
183
|
+
sessionId: params.sessionId,
|
|
184
|
+
update: {
|
|
185
|
+
sessionUpdate: "session_info_update",
|
|
186
|
+
updatedAt: session.updatedAt,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
/** Build the SessionModelState we advertise on session create/load/resume/fork. */
|
|
192
|
+
modelsState(currentModelId) {
|
|
193
|
+
return {
|
|
194
|
+
availableModels: getAvailableModels(),
|
|
195
|
+
currentModelId,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async setSessionMode(_params) {
|
|
199
|
+
// We don't advertise modes; this is a no-op accepting any request the
|
|
200
|
+
// client might send.
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Prompt Turn
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
async prompt(params) {
|
|
207
|
+
const session = this.sessions.get(params.sessionId);
|
|
208
|
+
if (!session) {
|
|
209
|
+
throw new Error(`Session not found: ${params.sessionId}`);
|
|
210
|
+
}
|
|
211
|
+
// The ACP spec serializes prompts per-session – the client should not send
|
|
212
|
+
// a new prompt while another is running. Defensively cancel any stale
|
|
213
|
+
// controller and wait for the previous loop to fully unwind so it stops
|
|
214
|
+
// mutating session.messages or emitting updates before we start the new
|
|
215
|
+
// one.
|
|
216
|
+
if (session.abortController) {
|
|
217
|
+
session.abortController.abort();
|
|
218
|
+
}
|
|
219
|
+
if (session.promptPromise) {
|
|
220
|
+
try {
|
|
221
|
+
await session.promptPromise;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// The previous loop's failure is already reported back to its caller.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const abortController = new AbortController();
|
|
228
|
+
session.abortController = abortController;
|
|
229
|
+
// Abort the prompt automatically if the underlying connection closes.
|
|
230
|
+
const onConnectionClose = () => abortController.abort();
|
|
231
|
+
const connSignal = this.connection.signal;
|
|
232
|
+
if (connSignal && !connSignal.aborted) {
|
|
233
|
+
connSignal.addEventListener("abort", onConnectionClose, { once: true });
|
|
234
|
+
}
|
|
235
|
+
else if (connSignal?.aborted) {
|
|
236
|
+
abortController.abort();
|
|
237
|
+
}
|
|
238
|
+
// Convert ACP content blocks into a GLM user message. Baseline: text +
|
|
239
|
+
// resource_link. Optional: embedded resources. Image blocks are routed
|
|
240
|
+
// through Z.AI Coding Plan Vision MCP (see image-preprocessor) so the
|
|
241
|
+
// result is always a plain string for the chat-completions endpoint.
|
|
242
|
+
if (isDebugEnabled()) {
|
|
243
|
+
for (const line of buildPromptBlockDiagnosticLines(params.prompt)) {
|
|
244
|
+
debug(line);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
let preprocessed;
|
|
248
|
+
preprocessed = await preprocessImageBlocks(params.prompt, this.visionClient, abortController.signal);
|
|
249
|
+
const { content: userContent, plainText: userText } = renderPromptBlocks(preprocessed.blocks);
|
|
250
|
+
session.messages.push({ role: "user", content: userContent });
|
|
251
|
+
// Echo back the client-supplied messageId on every response (success,
|
|
252
|
+
// cancelled, or error) so the client can correlate the turn.
|
|
253
|
+
const userMessageId = params.messageId ?? undefined;
|
|
254
|
+
let resolvePromptPromise;
|
|
255
|
+
session.promptPromise = new Promise((resolve) => {
|
|
256
|
+
resolvePromptPromise = resolve;
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
const { stopReason, usage } = await this.runPromptLoop(params.sessionId, session, abortController.signal);
|
|
260
|
+
session.abortController = null;
|
|
261
|
+
session.updatedAt = new Date().toISOString();
|
|
262
|
+
// Emit a session_info_update with the (possibly first-set) title and
|
|
263
|
+
// updated timestamp so clients can show fresh metadata.
|
|
264
|
+
const titleUpdate = session.title === null
|
|
265
|
+
? (() => {
|
|
266
|
+
const derived = userText.slice(0, 80).replace(/\s+/g, " ").trim();
|
|
267
|
+
session.title = derived.length > 0 ? derived : "New conversation";
|
|
268
|
+
return { title: session.title };
|
|
269
|
+
})()
|
|
270
|
+
: {};
|
|
271
|
+
await this.connection.sessionUpdate({
|
|
272
|
+
sessionId: params.sessionId,
|
|
273
|
+
update: {
|
|
274
|
+
sessionUpdate: "session_info_update",
|
|
275
|
+
updatedAt: session.updatedAt,
|
|
276
|
+
...titleUpdate,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
this.persistSession(params.sessionId, session);
|
|
280
|
+
const response = { stopReason };
|
|
281
|
+
if (usage)
|
|
282
|
+
response.usage = usage;
|
|
283
|
+
if (userMessageId)
|
|
284
|
+
response.userMessageId = userMessageId;
|
|
285
|
+
return response;
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
error(`prompt error: session=${params.sessionId}`, err instanceof Error ? err.message : String(err));
|
|
289
|
+
// If the abort happened concurrently with another error, prefer the
|
|
290
|
+
// cancelled stop reason – that's what the spec asks for.
|
|
291
|
+
if (abortController.signal.aborted) {
|
|
292
|
+
session.abortController = null;
|
|
293
|
+
const cancelled = { stopReason: "cancelled" };
|
|
294
|
+
if (userMessageId)
|
|
295
|
+
cancelled.userMessageId = userMessageId;
|
|
296
|
+
return cancelled;
|
|
297
|
+
}
|
|
298
|
+
session.abortController = null;
|
|
299
|
+
// Surface the error to the user as an agent message so the IDE displays
|
|
300
|
+
// something instead of a silent JSON-RPC error.
|
|
301
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
+
await safeSessionUpdate(this.connection, {
|
|
303
|
+
sessionId: params.sessionId,
|
|
304
|
+
update: {
|
|
305
|
+
sessionUpdate: "agent_message_chunk",
|
|
306
|
+
content: { type: "text", text: `\n\n[error] ${message}` },
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
connSignal?.removeEventListener("abort", onConnectionClose);
|
|
313
|
+
// Always resolve the promptPromise so a subsequent prompt can proceed.
|
|
314
|
+
session.promptPromise = null;
|
|
315
|
+
resolvePromptPromise();
|
|
316
|
+
for (const cleanup of preprocessed?.cleanups ?? []) {
|
|
317
|
+
try {
|
|
318
|
+
await cleanup();
|
|
319
|
+
}
|
|
320
|
+
catch { /* best effort */ }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async cancel(params) {
|
|
325
|
+
const session = this.sessions.get(params.sessionId);
|
|
326
|
+
session?.abortController?.abort();
|
|
327
|
+
}
|
|
328
|
+
async closeSession(params) {
|
|
329
|
+
const session = this.sessions.get(params.sessionId);
|
|
330
|
+
session?.abortController?.abort();
|
|
331
|
+
if (session) {
|
|
332
|
+
// Persist final state on close so a subsequent loadSession/resume can
|
|
333
|
+
// pick the conversation back up. closeSession only releases in-memory
|
|
334
|
+
// resources; the on-disk record is intentionally retained.
|
|
335
|
+
this.persistSession(params.sessionId, session);
|
|
336
|
+
}
|
|
337
|
+
this.sessions.delete(params.sessionId);
|
|
338
|
+
}
|
|
339
|
+
async listSessions(params) {
|
|
340
|
+
// Merge in-memory sessions with anything previously persisted to disk. The
|
|
341
|
+
// store is the source of truth for closed/restarted sessions; in-memory
|
|
342
|
+
// state takes precedence when both exist (it has the freshest title /
|
|
343
|
+
// updatedAt before persistence has fired).
|
|
344
|
+
//
|
|
345
|
+
// We use the metadata-only store API so we don't load every conversation's
|
|
346
|
+
// full message history just to render a session picker.
|
|
347
|
+
const merged = new Map();
|
|
348
|
+
if (this.sessionStore) {
|
|
349
|
+
for (const meta of this.sessionStore.listMetadata()) {
|
|
350
|
+
merged.set(meta.sessionId, {
|
|
351
|
+
cwd: meta.cwd,
|
|
352
|
+
title: meta.title,
|
|
353
|
+
updatedAt: meta.updatedAt,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const [sessionId, s] of this.sessions) {
|
|
358
|
+
merged.set(sessionId, {
|
|
359
|
+
cwd: s.cwd,
|
|
360
|
+
title: s.title,
|
|
361
|
+
updatedAt: s.updatedAt,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const all = Array.from(merged.entries());
|
|
365
|
+
const filtered = params.cwd
|
|
366
|
+
? all.filter(([, s]) => s.cwd === params.cwd)
|
|
367
|
+
: all;
|
|
368
|
+
// Newest-first so clients can render the picker without re-sorting.
|
|
369
|
+
filtered.sort(([, a], [, b]) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0);
|
|
370
|
+
return {
|
|
371
|
+
sessions: filtered.map(([sessionId, s]) => ({
|
|
372
|
+
sessionId,
|
|
373
|
+
cwd: s.cwd,
|
|
374
|
+
title: s.title ?? undefined,
|
|
375
|
+
updatedAt: s.updatedAt,
|
|
376
|
+
})),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Session load / fork / resume
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
async loadSession(params) {
|
|
383
|
+
const persisted = this.requirePersisted(params.sessionId);
|
|
384
|
+
// Restore in-memory state. We do NOT carry over the abortController /
|
|
385
|
+
// promptPromise — those are transient.
|
|
386
|
+
const restored = {
|
|
387
|
+
cwd: params.cwd,
|
|
388
|
+
messages: persisted.messages,
|
|
389
|
+
abortController: null,
|
|
390
|
+
promptPromise: null,
|
|
391
|
+
title: persisted.title,
|
|
392
|
+
updatedAt: persisted.updatedAt,
|
|
393
|
+
model: persisted.model,
|
|
394
|
+
};
|
|
395
|
+
this.sessions.set(params.sessionId, restored);
|
|
396
|
+
// Replay user/assistant text turns so the client can rehydrate its UI.
|
|
397
|
+
// Tool / system messages are skipped — they're internal and the client
|
|
398
|
+
// doesn't render them on its own.
|
|
399
|
+
await this.replayMessages(params.sessionId, persisted.messages);
|
|
400
|
+
return { models: this.modelsState(persisted.model) };
|
|
401
|
+
}
|
|
402
|
+
async unstable_forkSession(params) {
|
|
403
|
+
const source = this.sessions.get(params.sessionId);
|
|
404
|
+
const persisted = source
|
|
405
|
+
? this.snapshot(params.sessionId, source)
|
|
406
|
+
: this.requirePersisted(params.sessionId);
|
|
407
|
+
const newSessionId = randomUUID();
|
|
408
|
+
const forkedTitle = persisted.title === null ? null : `${persisted.title} (fork)`;
|
|
409
|
+
const forked = {
|
|
410
|
+
cwd: params.cwd,
|
|
411
|
+
// Deep-clone messages so the fork doesn't share state with the parent.
|
|
412
|
+
messages: structuredClone(persisted.messages),
|
|
413
|
+
abortController: null,
|
|
414
|
+
promptPromise: null,
|
|
415
|
+
title: forkedTitle,
|
|
416
|
+
updatedAt: new Date().toISOString(),
|
|
417
|
+
model: persisted.model,
|
|
418
|
+
};
|
|
419
|
+
this.sessions.set(newSessionId, forked);
|
|
420
|
+
this.persistSession(newSessionId, forked);
|
|
421
|
+
return {
|
|
422
|
+
sessionId: newSessionId,
|
|
423
|
+
models: this.modelsState(forked.model),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
async resumeSession(params) {
|
|
427
|
+
const persisted = this.requirePersisted(params.sessionId);
|
|
428
|
+
const restored = {
|
|
429
|
+
cwd: params.cwd,
|
|
430
|
+
messages: persisted.messages,
|
|
431
|
+
abortController: null,
|
|
432
|
+
promptPromise: null,
|
|
433
|
+
title: persisted.title,
|
|
434
|
+
updatedAt: persisted.updatedAt,
|
|
435
|
+
model: persisted.model,
|
|
436
|
+
};
|
|
437
|
+
this.sessions.set(params.sessionId, restored);
|
|
438
|
+
// Resume does NOT replay history — the client keeps its own UI state and
|
|
439
|
+
// just wants the agent to pick up where it left off.
|
|
440
|
+
return { models: this.modelsState(persisted.model) };
|
|
441
|
+
}
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// Persistence helpers
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
snapshot(sessionId, session) {
|
|
446
|
+
return {
|
|
447
|
+
sessionId,
|
|
448
|
+
cwd: session.cwd,
|
|
449
|
+
messages: session.messages,
|
|
450
|
+
title: session.title,
|
|
451
|
+
updatedAt: session.updatedAt,
|
|
452
|
+
model: session.model,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
persistSession(sessionId, session) {
|
|
456
|
+
if (!this.sessionStore)
|
|
457
|
+
return;
|
|
458
|
+
try {
|
|
459
|
+
this.sessionStore.save(this.snapshot(sessionId, session));
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
+
process.stderr.write(`[glm-acp-agent] warning: failed to persist session ${sessionId}: ${msg}\n`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
requirePersisted(sessionId) {
|
|
467
|
+
if (!this.sessionStore) {
|
|
468
|
+
throw new Error("Session persistence is disabled");
|
|
469
|
+
}
|
|
470
|
+
const persisted = this.sessionStore.load(sessionId);
|
|
471
|
+
if (!persisted) {
|
|
472
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
473
|
+
}
|
|
474
|
+
return persisted;
|
|
475
|
+
}
|
|
476
|
+
async replayMessages(sessionId, messages) {
|
|
477
|
+
for (const msg of messages) {
|
|
478
|
+
if (msg.role === "user") {
|
|
479
|
+
const text = stringifyUserMessage(msg.content);
|
|
480
|
+
if (text.length === 0)
|
|
481
|
+
continue;
|
|
482
|
+
await this.connection.sessionUpdate({
|
|
483
|
+
sessionId,
|
|
484
|
+
update: {
|
|
485
|
+
sessionUpdate: "user_message_chunk",
|
|
486
|
+
content: { type: "text", text },
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
else if (msg.role === "assistant") {
|
|
491
|
+
const content = msg.content;
|
|
492
|
+
const text = typeof content === "string"
|
|
493
|
+
? content
|
|
494
|
+
: Array.isArray(content)
|
|
495
|
+
? content
|
|
496
|
+
.filter((p) => typeof p === "object" &&
|
|
497
|
+
p !== null &&
|
|
498
|
+
p.type === "text" &&
|
|
499
|
+
typeof p.text === "string")
|
|
500
|
+
.map((p) => p.text)
|
|
501
|
+
.join("")
|
|
502
|
+
: "";
|
|
503
|
+
if (text.length === 0)
|
|
504
|
+
continue;
|
|
505
|
+
await this.connection.sessionUpdate({
|
|
506
|
+
sessionId,
|
|
507
|
+
update: {
|
|
508
|
+
sessionUpdate: "agent_message_chunk",
|
|
509
|
+
content: { type: "text", text },
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// system / tool messages are not replayed — they're internal.
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Internal prompt loop
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
/**
|
|
520
|
+
* Runs the full prompt/tool-calling loop until the model stops or is cancelled.
|
|
521
|
+
*
|
|
522
|
+
* Returns the ACP stop reason and optional token usage reported by the model.
|
|
523
|
+
*/
|
|
524
|
+
async runPromptLoop(sessionId, session, signal) {
|
|
525
|
+
const executor = new ToolExecutor(this.connection, sessionId, this.clientCapabilities, signal, this.visionClient);
|
|
526
|
+
let lastUsage;
|
|
527
|
+
for (let turn = 0; turn < this.maxTurns; turn++) {
|
|
528
|
+
if (signal.aborted)
|
|
529
|
+
return { stopReason: "cancelled" };
|
|
530
|
+
debug(`promptLoop: turn=${turn} session=${sessionId} model=${session.model} messages=${session.messages.length}`);
|
|
531
|
+
const toolCalls = [];
|
|
532
|
+
let assistantText = "";
|
|
533
|
+
let lastStopReason;
|
|
534
|
+
// Stream the GLM response. The session's currently-selected model wins;
|
|
535
|
+
// it's mutated by `unstable_setSessionModel` between turns.
|
|
536
|
+
for await (const chunk of this.glm.streamChat(session.messages, signal, {
|
|
537
|
+
model: session.model,
|
|
538
|
+
})) {
|
|
539
|
+
if (signal.aborted)
|
|
540
|
+
return { stopReason: "cancelled" };
|
|
541
|
+
if (chunk.thinking) {
|
|
542
|
+
await this.connection.sessionUpdate({
|
|
543
|
+
sessionId,
|
|
544
|
+
update: {
|
|
545
|
+
sessionUpdate: "agent_thought_chunk",
|
|
546
|
+
content: { type: "text", text: chunk.thinking },
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (chunk.text) {
|
|
551
|
+
assistantText += chunk.text;
|
|
552
|
+
await this.connection.sessionUpdate({
|
|
553
|
+
sessionId,
|
|
554
|
+
update: {
|
|
555
|
+
sessionUpdate: "agent_message_chunk",
|
|
556
|
+
content: { type: "text", text: chunk.text },
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (chunk.toolCall) {
|
|
561
|
+
debug(`promptLoop: toolCall id=${chunk.toolCall.id} name=${chunk.toolCall.name}`);
|
|
562
|
+
toolCalls.push(chunk.toolCall);
|
|
563
|
+
}
|
|
564
|
+
if (chunk.usage) {
|
|
565
|
+
lastUsage = chunk.usage;
|
|
566
|
+
}
|
|
567
|
+
if (chunk.done) {
|
|
568
|
+
lastStopReason = chunk.stopReason;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Record the assistant turn in history so the model has full context for
|
|
572
|
+
// the next iteration.
|
|
573
|
+
if (toolCalls.length > 0) {
|
|
574
|
+
session.messages.push({
|
|
575
|
+
role: "assistant",
|
|
576
|
+
content: assistantText.length > 0 ? assistantText : null,
|
|
577
|
+
tool_calls: toolCalls.map((tc) => ({
|
|
578
|
+
id: tc.id,
|
|
579
|
+
type: "function",
|
|
580
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
581
|
+
})),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
else if (assistantText.length > 0) {
|
|
585
|
+
session.messages.push({ role: "assistant", content: assistantText });
|
|
586
|
+
}
|
|
587
|
+
// No tool calls => model is done.
|
|
588
|
+
if (toolCalls.length === 0) {
|
|
589
|
+
return { stopReason: this.mapStopReason(lastStopReason), usage: lastUsage };
|
|
590
|
+
}
|
|
591
|
+
// Execute tool calls in declaration order and feed each result back.
|
|
592
|
+
for (const tc of toolCalls) {
|
|
593
|
+
if (signal.aborted)
|
|
594
|
+
return { stopReason: "cancelled", usage: lastUsage };
|
|
595
|
+
const result = await executor.execute(tc.id, tc.name, tc.arguments);
|
|
596
|
+
debug(`promptLoop: toolResult id=${tc.id} name=${tc.name} contentLength=${result.content.length}`);
|
|
597
|
+
session.messages.push({
|
|
598
|
+
role: "tool",
|
|
599
|
+
tool_call_id: tc.id,
|
|
600
|
+
content: result.content,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// Loop and continue – GLM expects a follow-up completion now that it has
|
|
604
|
+
// tool results.
|
|
605
|
+
}
|
|
606
|
+
// Reached MAX_TURNS without resolution.
|
|
607
|
+
return { stopReason: "max_turn_requests", usage: lastUsage };
|
|
608
|
+
}
|
|
609
|
+
mapStopReason(stopReason) {
|
|
610
|
+
switch (stopReason) {
|
|
611
|
+
case "length":
|
|
612
|
+
return "max_tokens";
|
|
613
|
+
case "content_filter":
|
|
614
|
+
return "refusal";
|
|
615
|
+
case "stop":
|
|
616
|
+
case "tool_calls":
|
|
617
|
+
case undefined:
|
|
618
|
+
case null:
|
|
619
|
+
case "":
|
|
620
|
+
return "end_turn";
|
|
621
|
+
default:
|
|
622
|
+
return "end_turn";
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** Names of tools we expose given the client's capabilities. */
|
|
626
|
+
availableToolNames() {
|
|
627
|
+
// If the client never sent capabilities at all (initialize wasn't called,
|
|
628
|
+
// or it omitted the field), advertise the full set so the system prompt
|
|
629
|
+
// still mentions every tool. The executor will surface a clean error if
|
|
630
|
+
// the model invokes one whose capability is missing.
|
|
631
|
+
if (!this.clientCapabilities) {
|
|
632
|
+
return TOOL_DEFINITIONS.map((t) => t.function.name);
|
|
633
|
+
}
|
|
634
|
+
const fs = this.clientCapabilities.fs ?? {};
|
|
635
|
+
const terminal = this.clientCapabilities.terminal ?? false;
|
|
636
|
+
const names = [];
|
|
637
|
+
if (fs.readTextFile)
|
|
638
|
+
names.push("read_file");
|
|
639
|
+
if (fs.writeTextFile)
|
|
640
|
+
names.push("write_file");
|
|
641
|
+
if (terminal)
|
|
642
|
+
names.push("list_files", "run_command");
|
|
643
|
+
// Web tools and image_analysis run inside the agent process, so they are
|
|
644
|
+
// unconditional except when vision is explicitly disabled.
|
|
645
|
+
names.push("web_search", "web_reader");
|
|
646
|
+
if (this.visionClientExplicit ? this._visionClient !== null : true) {
|
|
647
|
+
names.push("image_analysis");
|
|
648
|
+
}
|
|
649
|
+
return names;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Render a list of ACP content blocks (after image preprocessing) into the
|
|
654
|
+
* plain-string user message we send to the chat-completions endpoint.
|
|
655
|
+
*
|
|
656
|
+
* `plainText` mirrors `content` and is used by the agent to derive a session
|
|
657
|
+
* title.
|
|
658
|
+
*/
|
|
659
|
+
function renderPromptBlocks(blocks) {
|
|
660
|
+
const textParts = [];
|
|
661
|
+
for (const block of blocks) {
|
|
662
|
+
switch (block.type) {
|
|
663
|
+
case "text":
|
|
664
|
+
textParts.push(block.text);
|
|
665
|
+
break;
|
|
666
|
+
case "resource_link":
|
|
667
|
+
textParts.push(`[${block.name}](${block.uri})`);
|
|
668
|
+
break;
|
|
669
|
+
case "resource": {
|
|
670
|
+
const res = block.resource;
|
|
671
|
+
if ("text" in res && typeof res.text === "string") {
|
|
672
|
+
textParts.push(`<resource uri="${res.uri}">\n${res.text}\n</resource>`);
|
|
673
|
+
}
|
|
674
|
+
else if ("blob" in res) {
|
|
675
|
+
textParts.push(`[binary resource](${res.uri})`);
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
case "image": {
|
|
680
|
+
// Defensive: image blocks should have been preprocessed away upstream.
|
|
681
|
+
textParts.push(`[image: ${block.mimeType}]`);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
case "audio":
|
|
685
|
+
textParts.push("[unsupported audio block]");
|
|
686
|
+
break;
|
|
687
|
+
default:
|
|
688
|
+
textParts.push(`[unknown block type ${block.type}]`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const plainText = textParts.join("\n");
|
|
692
|
+
return { content: plainText, plainText };
|
|
693
|
+
}
|
|
694
|
+
/** Flatten the `content` of a user message into a plain string for replay. */
|
|
695
|
+
function stringifyUserMessage(content) {
|
|
696
|
+
return typeof content === "string" ? content : "";
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Read an `AGENTS.md` (preferred) or `CLAUDE.md` from the session's cwd, returning
|
|
700
|
+
* its contents capped to {@link PROJECT_CONTEXT_CAP_CHARS} characters. Read errors
|
|
701
|
+
* (file missing, no permission, directory missing) are intentionally swallowed —
|
|
702
|
+
* project context is optional, and a missing file is the common case.
|
|
703
|
+
*
|
|
704
|
+
* Called once at `newSession` time (not per prompt) so the project context is
|
|
705
|
+
* stable across the conversation.
|
|
706
|
+
*/
|
|
707
|
+
function loadProjectContext(cwd) {
|
|
708
|
+
for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
|
|
709
|
+
let contents;
|
|
710
|
+
try {
|
|
711
|
+
contents = readFileSync(pathJoin(cwd, filename), { encoding: "utf-8" });
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (contents.length > PROJECT_CONTEXT_CAP_CHARS) {
|
|
717
|
+
contents = contents.slice(0, PROJECT_CONTEXT_CAP_CHARS);
|
|
718
|
+
}
|
|
719
|
+
return contents;
|
|
720
|
+
}
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
/** sessionUpdate that swallows transport errors during error reporting. */
|
|
724
|
+
async function safeSessionUpdate(connection, params) {
|
|
725
|
+
try {
|
|
726
|
+
await connection.sessionUpdate(params);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// best-effort
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
//# sourceMappingURL=agent.js.map
|