glm-acp-agent 1.1.3 → 1.2.0

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