glm-acp-agent 1.1.2 → 1.1.4

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