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.
Files changed (102) hide show
  1. package/README.md +365 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +62 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/llm/credentials.d.ts +15 -0
  7. package/dist/llm/credentials.d.ts.map +1 -0
  8. package/dist/llm/credentials.js +54 -0
  9. package/dist/llm/credentials.js.map +1 -0
  10. package/dist/llm/glm-client.d.ts +63 -0
  11. package/dist/llm/glm-client.d.ts.map +1 -0
  12. package/dist/llm/glm-client.js +217 -0
  13. package/dist/llm/glm-client.js.map +1 -0
  14. package/dist/llm/logger.d.ts +19 -0
  15. package/dist/llm/logger.d.ts.map +1 -0
  16. package/dist/llm/logger.js +44 -0
  17. package/dist/llm/logger.js.map +1 -0
  18. package/dist/protocol/agent.d.ts +77 -0
  19. package/dist/protocol/agent.d.ts.map +1 -0
  20. package/dist/protocol/agent.js +732 -0
  21. package/dist/protocol/agent.js.map +1 -0
  22. package/dist/protocol/connection.d.ts +9 -0
  23. package/dist/protocol/connection.d.ts.map +1 -0
  24. package/dist/protocol/connection.js +18 -0
  25. package/dist/protocol/connection.js.map +1 -0
  26. package/dist/protocol/image-preprocessor.d.ts +32 -0
  27. package/dist/protocol/image-preprocessor.d.ts.map +1 -0
  28. package/dist/protocol/image-preprocessor.js +149 -0
  29. package/dist/protocol/image-preprocessor.js.map +1 -0
  30. package/dist/protocol/session-store.d.ts +54 -0
  31. package/dist/protocol/session-store.d.ts.map +1 -0
  32. package/dist/protocol/session-store.js +109 -0
  33. package/dist/protocol/session-store.js.map +1 -0
  34. package/dist/protocol/system-prompt.d.ts +29 -0
  35. package/dist/protocol/system-prompt.d.ts.map +1 -0
  36. package/dist/protocol/system-prompt.js +103 -0
  37. package/dist/protocol/system-prompt.js.map +1 -0
  38. package/dist/setup.d.ts +8 -0
  39. package/dist/setup.d.ts.map +1 -0
  40. package/dist/setup.js +95 -0
  41. package/dist/setup.js.map +1 -0
  42. package/dist/tests/agent.test.d.ts +2 -0
  43. package/dist/tests/agent.test.d.ts.map +1 -0
  44. package/dist/tests/agent.test.js +1118 -0
  45. package/dist/tests/agent.test.js.map +1 -0
  46. package/dist/tests/credentials.test.d.ts +2 -0
  47. package/dist/tests/credentials.test.d.ts.map +1 -0
  48. package/dist/tests/credentials.test.js +112 -0
  49. package/dist/tests/credentials.test.js.map +1 -0
  50. package/dist/tests/executor.test.d.ts +2 -0
  51. package/dist/tests/executor.test.d.ts.map +1 -0
  52. package/dist/tests/executor.test.js +497 -0
  53. package/dist/tests/executor.test.js.map +1 -0
  54. package/dist/tests/glm-client.test.d.ts +2 -0
  55. package/dist/tests/glm-client.test.d.ts.map +1 -0
  56. package/dist/tests/glm-client.test.js +205 -0
  57. package/dist/tests/glm-client.test.js.map +1 -0
  58. package/dist/tests/image-preprocessor.test.d.ts +2 -0
  59. package/dist/tests/image-preprocessor.test.d.ts.map +1 -0
  60. package/dist/tests/image-preprocessor.test.js +133 -0
  61. package/dist/tests/image-preprocessor.test.js.map +1 -0
  62. package/dist/tests/integration.test.d.ts +2 -0
  63. package/dist/tests/integration.test.d.ts.map +1 -0
  64. package/dist/tests/integration.test.js +192 -0
  65. package/dist/tests/integration.test.js.map +1 -0
  66. package/dist/tests/logger.test.d.ts +2 -0
  67. package/dist/tests/logger.test.d.ts.map +1 -0
  68. package/dist/tests/logger.test.js +14 -0
  69. package/dist/tests/logger.test.js.map +1 -0
  70. package/dist/tests/mcp-arg-remap.test.d.ts +2 -0
  71. package/dist/tests/mcp-arg-remap.test.d.ts.map +1 -0
  72. package/dist/tests/mcp-arg-remap.test.js +65 -0
  73. package/dist/tests/mcp-arg-remap.test.js.map +1 -0
  74. package/dist/tests/vision-mcp-client.test.d.ts +2 -0
  75. package/dist/tests/vision-mcp-client.test.d.ts.map +1 -0
  76. package/dist/tests/vision-mcp-client.test.js +129 -0
  77. package/dist/tests/vision-mcp-client.test.js.map +1 -0
  78. package/dist/tests/zai-mcp-client.test.d.ts +2 -0
  79. package/dist/tests/zai-mcp-client.test.d.ts.map +1 -0
  80. package/dist/tests/zai-mcp-client.test.js +386 -0
  81. package/dist/tests/zai-mcp-client.test.js.map +1 -0
  82. package/dist/tools/definitions.d.ts +18 -0
  83. package/dist/tools/definitions.d.ts.map +1 -0
  84. package/dist/tools/definitions.js +149 -0
  85. package/dist/tools/definitions.js.map +1 -0
  86. package/dist/tools/executor.d.ts +48 -0
  87. package/dist/tools/executor.d.ts.map +1 -0
  88. package/dist/tools/executor.js +670 -0
  89. package/dist/tools/executor.js.map +1 -0
  90. package/dist/tools/mcp-arg-remap.d.ts +21 -0
  91. package/dist/tools/mcp-arg-remap.d.ts.map +1 -0
  92. package/dist/tools/mcp-arg-remap.js +70 -0
  93. package/dist/tools/mcp-arg-remap.js.map +1 -0
  94. package/dist/tools/vision-mcp-client.d.ts +38 -0
  95. package/dist/tools/vision-mcp-client.d.ts.map +1 -0
  96. package/dist/tools/vision-mcp-client.js +191 -0
  97. package/dist/tools/vision-mcp-client.js.map +1 -0
  98. package/dist/tools/zai-mcp-client.d.ts +24 -0
  99. package/dist/tools/zai-mcp-client.d.ts.map +1 -0
  100. package/dist/tools/zai-mcp-client.js +189 -0
  101. package/dist/tools/zai-mcp-client.js.map +1 -0
  102. 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