@nexrall/code-core 1.4.12 → 1.4.14

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.
@@ -102,4 +102,17 @@ export declare function compactMessagesForResume(messages: Message[], opts: {
102
102
  onNotice?: (text: string) => void;
103
103
  }): Promise<boolean>;
104
104
  export declare function runAgentLoop(initialMessages: Message[], options: AgentLoopOptions): Promise<Message[]>;
105
+ /**
106
+ * Cut a history back to the last point the API will accept as a resumable
107
+ * prefix, WITHOUT mutating the input.
108
+ *
109
+ * Anthropic rejects a conversation whose final assistant turn contains
110
+ * `tool_use` blocks that never got a matching `tool_result`. The main loop
111
+ * always appends in complete pairs so it can't produce that shape, but a crash
112
+ * in the middle of executing a tool round can: the assistant turn was already
113
+ * pushed, the tool_result carrier never was. Dropping that dangling assistant
114
+ * turn costs one model response and makes the rest of the (often very long)
115
+ * history resumable, instead of throwing all of it away.
116
+ */
117
+ export declare function trimToResumableBoundary(messages: Message[]): Message[];
105
118
  //# sourceMappingURL=loop.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAsKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AA4TD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsHD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CA4nBpB"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EAChB,UAAU,EACX,MAAM,UAAU,CAAC;AAyKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AA+UD,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAM7D;AAsBD,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,aAA+G,CAAC;AAC7I,gGAAgG;AAChG,eAAO,MAAM,aAAa,QAA2J,CAAC;AAEtL;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AAUD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAwBxD;AAoBD,MAAM,WAAW,cAAc;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3D,aAAa,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClE,qFAAqF;IACrF,aAAa,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,YAAY,IAAI,cAAc,CAE7C;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,EAAE,EAAE,OAAO,EACX,MAAM,CAAC,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,IAAI,CAoDN;AAED,kFAAkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CA6B5D;AAmBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,SAAI,GAAG,MAAM,CAgCpF;AAsKD,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,OAAO,EAAE,EACnB,IAAI,EAAE;IACJ,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,OAAO,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,GACA,OAAO,CAAC,OAAO,CAAC,CAqElB;AAID,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CA+sBpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,CAqCtE"}
@@ -45,15 +45,19 @@ exports.pruneOldToolResults = pruneOldToolResults;
45
45
  exports.estimateTokensRough = estimateTokensRough;
46
46
  exports.compactMessagesForResume = compactMessagesForResume;
47
47
  exports.runAgentLoop = runAgentLoop;
48
+ exports.trimToResumableBoundary = trimToResumableBoundary;
49
+ const types_1 = require("../types");
48
50
  const client_1 = require("../api/client");
49
51
  const executor_1 = require("../tools/executor");
50
52
  const agentTypes_1 = require("./agentTypes");
53
+ const skills_1 = require("./skills");
51
54
  const rules_1 = require("../permissions/rules");
52
55
  const sandbox_1 = require("../tools/sandbox");
53
56
  const index_1 = require("../plugins/index");
54
57
  const testIntegrity_1 = require("./testIntegrity");
55
58
  const flaky_1 = require("./flaky");
56
59
  const claimEvidence_1 = require("./claimEvidence");
60
+ const memory_1 = require("./memory");
57
61
  const fs = __importStar(require("fs"));
58
62
  const path = __importStar(require("path"));
59
63
  const child_process_1 = require("child_process");
@@ -299,6 +303,8 @@ function humanDescription(name, input) {
299
303
  const preview = typeof input.prompt === 'string' ? input.prompt.slice(0, 60) : '';
300
304
  return `Sub-task: ${desc || preview}${!desc && preview.length === 60 ? '…' : ''}`;
301
305
  }
306
+ case 'use_skill':
307
+ return `Use skill: /${input.name ?? '(unknown)'}`;
302
308
  case 'get_diagnostics':
303
309
  return input.path ? `Get diagnostics: ${input.path}` : 'Get workspace diagnostics';
304
310
  case 'go_to_definition':
@@ -362,6 +368,22 @@ async function runSubTask(input, options, agentTypes) {
362
368
  nexrallMd: subNexrallMd,
363
369
  requestPermission: gatedPermission,
364
370
  onText: () => { }, // sub-agent text is returned as the tool result, not streamed live
371
+ // Forwarded DELIBERATELY, and it must be a real handler rather than a no-op.
372
+ //
373
+ // A sub-agent streams no text (onText above is a no-op) but it DOES stream thinking
374
+ // through the parent's UI (see onThinking/onThinkingDelta below), and thinking sets
375
+ // `emittedToCaller`. So a sub-agent stream that dies after reasoning genuinely has
376
+ // rendered output to discard — a no-op here would let the restart proceed and then
377
+ // re-stream that reasoning on top of the copy still on screen, the exact duplication
378
+ // the opt-in exists to prevent.
379
+ //
380
+ // Forwarding is safe because the parent's own output is already closed by this
381
+ // point: dispatching the `task` tool goes through options.onToolUse, which finalizes
382
+ // the parent's bubble (VS Code) / flushes the renderer (CLI) before the sub-agent
383
+ // starts. The only live, discardable element at restart time is the sub-agent's own
384
+ // thinking block. Completed tool rows are left alone — those are real side effects
385
+ // that actually happened.
386
+ onStreamRestart: (reason, chars) => options.onStreamRestart?.(reason, chars),
365
387
  // Forward tool events with isSubTask=true so the UI can render a badge
366
388
  // instead of prepending "[sub-task]" to the tool name (which caused double-prefix
367
389
  // when the name was already labelled, and mixed display concerns into the data layer).
@@ -798,6 +820,11 @@ async function autoCompactMessages(messages, options, ledger) {
798
820
  env: options.env,
799
821
  clientType: options.clientType,
800
822
  abortSignal: options.abortSignal,
823
+ // The summariser renders NOTHING (onEvent below is a no-op) and its result is
824
+ // read only from the returned message, so a restart has nothing to roll back —
825
+ // always safe. Worth enabling: a blip here used to abandon compaction entirely,
826
+ // which then let the very next turn hit the context wall it was meant to prevent.
827
+ allowRestartAfterRender: true,
801
828
  }, () => { });
802
829
  summary = reply.content
803
830
  .filter((b) => b.type === 'text')
@@ -831,6 +858,45 @@ async function autoCompactMessages(messages, options, ledger) {
831
858
  void kept;
832
859
  return true;
833
860
  }
861
+ // ─── Periodic memory compaction ────────────────────────────────────────────
862
+ // A memory file (project or global — see agent/memory.ts) can grow large over
863
+ // many sessions since memory_write only ever appends. writeMemory() already
864
+ // applies an immediate, synchronous byte-cap eviction (oldest entries dropped)
865
+ // as a hard backstop, but that's a blunt instrument — this periodically
866
+ // consolidates the file with a real LLM summarization pass instead, so old
867
+ // facts are condensed into fewer, denser bullets rather than silently lost.
868
+ // Checked opportunistically right after a successful memory_write (see the
869
+ // call site below) rather than on every tool call — cheap to check (a single
870
+ // file stat), and memory_write is the only thing that can push a file over
871
+ // the trigger threshold in the first place.
872
+ async function maybeCompactMemory(scope, options) {
873
+ try {
874
+ await (0, memory_1.compactMemoryIfNeeded)(scope, options.workDir, async (prompt) => {
875
+ const reply = await (0, client_1.streamChat)([{ role: 'user', content: [{ type: 'text', text: prompt }] }], {
876
+ // Same reasoning as autoCompactMessages' summariser: a mechanical
877
+ // consolidation task, always run on the cheapest 1M-context tier
878
+ // regardless of the user's chosen model for the actual conversation.
879
+ model: 'turbo',
880
+ mode: 'ask',
881
+ env: options.env,
882
+ clientType: options.clientType,
883
+ abortSignal: options.abortSignal,
884
+ // Same as the transcript summariser: no rendered output, result read only from
885
+ // the returned message, so restarting on a blip is always safe.
886
+ allowRestartAfterRender: true,
887
+ }, () => { });
888
+ return reply.content
889
+ .filter((b) => b.type === 'text')
890
+ .map((b) => b.text ?? '')
891
+ .join('');
892
+ });
893
+ }
894
+ catch {
895
+ // Best-effort — a failed/aborted compaction just means the file stays as-is
896
+ // until the next memory_write call tries again; writeMemory's synchronous
897
+ // byte cap already bounds worst-case growth in the meantime.
898
+ }
899
+ }
834
900
  // ─── Resume-time proactive compaction ─────────────────────────────────────────
835
901
  //
836
902
  // The in-loop auto-compact above only reacts to `lastPromptTokens`, which is
@@ -940,6 +1006,11 @@ async function runAgentLoop(initialMessages, options) {
940
1006
  // catalogue (sub-agents can't spawn further), but every level resolves types.
941
1007
  const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir);
942
1008
  const agentsCatalogue = depth === 0 ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
1009
+ // Skills catalogue — unlike agentsCatalogue, available at every depth: a skill is
1010
+ // just a reusable prompt template (via use_skill), not another spawn point, so
1011
+ // sub-agents benefit from the same playbooks without the recursion concerns that
1012
+ // gate agentsCatalogue to the top level.
1013
+ const skillsCatalogue = (0, skills_1.summariseSkills)((0, skills_1.loadSkills)(options.workDir));
943
1014
  // Optional OS-level bash sandbox (opt-in via settings.json "sandbox").
944
1015
  const settings = (0, rules_1.loadSettings)(options.workDir);
945
1016
  const sandboxCfg = (0, sandbox_1.parseSandboxConfig)(settings.raw.sandbox) ?? undefined;
@@ -965,6 +1036,11 @@ async function runAgentLoop(initialMessages, options) {
965
1036
  // iteration backstop, so we can tell the user what happened rather than
966
1037
  // silently returning a half-done task.
967
1038
  let completedCleanly = false;
1039
+ // Tool rounds actually completed in this call. Counted explicitly rather than derived
1040
+ // from `messages.length` because auto-compaction splices the history SHORTER mid-run:
1041
+ // a long session that compacted and then lost its connection would show a negative
1042
+ // length delta and be judged "no progress", discarding the work it most needs to keep.
1043
+ let completedRounds = 0;
968
1044
  let stalledOut = false; // tripped the runaway guard (all-failed rounds)
969
1045
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
970
1046
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
@@ -1065,8 +1141,6 @@ async function runAgentLoop(initialMessages, options) {
1065
1141
  if (!autoContinue && iteration === budget - 5 && budget > 5) {
1066
1142
  options.onText(`\n⚠️ Approaching the ${budget}-step limit (step ${iteration + 1}). Please wrap up and summarise what has been done.\n`);
1067
1143
  }
1068
- // Collect tool_use blocks emitted during this stream
1069
- const pendingToolUse = [];
1070
1144
  // Build SSE event handler
1071
1145
  const onEvent = (event) => {
1072
1146
  switch (event.type) {
@@ -1083,21 +1157,33 @@ async function runAgentLoop(initialMessages, options) {
1083
1157
  options.onThinkingProgress?.(event.tokens);
1084
1158
  break;
1085
1159
  case 'tool_use':
1086
- // Collect tool use blocks they may arrive incrementally
1087
- // We use message_complete to finalize them, so just track here
1088
- pendingToolUse.push({
1089
- type: 'tool_use',
1090
- id: event.id,
1091
- name: event.name,
1092
- input: event.input,
1093
- });
1160
+ // Nothing to do here. Streamed tool_use events are a PREVIEW only (their
1161
+ // `input` can still be partial JSON mid-stream); the blocks actually
1162
+ // executed are read from the completed message's content at the end of
1163
+ // this iteration. An accumulator here used to exist but was never read —
1164
+ // keeping it invited the assumption that dispatch happens from the stream,
1165
+ // which would break the restart guarantee below.
1094
1166
  break;
1095
1167
  case 'usage':
1096
- lastPromptTokens =
1097
- (event.usage.input_tokens ?? 0) +
1098
- (event.usage.cache_creation_input_tokens ?? 0) +
1099
- (event.usage.cache_read_input_tokens ?? 0);
1100
- options.onUsage(event.usage);
1168
+ // A `partial` report belongs to an attempt that was cut short and restarted.
1169
+ // Its token counts are real spend (already billed upstream) and are forwarded
1170
+ // so a cost display stays honest — but they must NOT drive `lastPromptTokens`,
1171
+ // which feeds the auto-compaction trigger: a truncated attempt understates the
1172
+ // prompt size, and letting it overwrite a correct reading would suppress a
1173
+ // compaction the next turn actually needs.
1174
+ if (!event.partial) {
1175
+ // A `replayed` report DOES update this: it describes the real, complete turn
1176
+ // (served from the backend's idempotency cache after a dropped connection),
1177
+ // so its prompt size is accurate and needed for the compaction decision.
1178
+ // Only `partial` — a truncated attempt — must be excluded.
1179
+ lastPromptTokens =
1180
+ (event.usage.input_tokens ?? 0) +
1181
+ (event.usage.cache_creation_input_tokens ?? 0) +
1182
+ (event.usage.cache_read_input_tokens ?? 0);
1183
+ }
1184
+ // Both flags mean "do not add these tokens to a running cost total": `partial`
1185
+ // was a discarded attempt, `replayed` was already counted when first billed.
1186
+ options.onUsage(event.usage, event.partial === true || event.replayed === true);
1101
1187
  break;
1102
1188
  case 'message_complete':
1103
1189
  // The complete message is returned from streamChat, we handle it below
@@ -1108,11 +1194,29 @@ async function runAgentLoop(initialMessages, options) {
1108
1194
  case 'retry_resolved':
1109
1195
  options.onRetryResolved?.();
1110
1196
  break;
1197
+ case 'stream_restart':
1198
+ // The attempt that died had already rendered text/thinking; the replacement
1199
+ // attempt re-sends the turn in full, so the caller must drop that partial
1200
+ // render or it shows up twice.
1201
+ //
1202
+ // Nothing else needs undoing, and that is a property of the code below, not
1203
+ // an assumption: tools are dispatched from `assistantMessage.content` AFTER
1204
+ // streamChat() returns, and a restarted attempt threw instead of returning.
1205
+ // So no tool ran, no file changed, no checkpoint or hook fired.
1206
+ options.onStreamRestart?.(event.reason, event.discardedChars);
1207
+ break;
1111
1208
  case 'balance_status':
1112
1209
  options.onBalanceStatus?.(event.balance, event.zero);
1113
1210
  break;
1114
1211
  case 'done':
1212
+ break;
1115
1213
  case 'error':
1214
+ // Reaching the loop means the stream did NOT reject on this — i.e. the turn's
1215
+ // message had already been delivered and the error arrived late (typically the
1216
+ // backend's post-turn billing write failing). The answer stands, so this must
1217
+ // not fail the turn, but it must not vanish either: route it to the system-
1218
+ // notice channel so it is visible and logged rather than silently swallowed.
1219
+ options.onNotice?.(`⚠️ ${event.message}`);
1116
1220
  break;
1117
1221
  }
1118
1222
  };
@@ -1130,6 +1234,12 @@ async function runAgentLoop(initialMessages, options) {
1130
1234
  abortSignal: options.abortSignal,
1131
1235
  extraTools: options.mcpManager?.getAnthropicTools(),
1132
1236
  agents: agentsCatalogue || undefined,
1237
+ skills: skillsCatalogue || undefined,
1238
+ // Only allow a post-render restart when the caller actually implements the
1239
+ // rollback. Without a handler the partial output can't be un-rendered, so we
1240
+ // keep the old conservative behaviour (fail the turn) rather than duplicate
1241
+ // text on screen.
1242
+ allowRestartAfterRender: !!options.onStreamRestart,
1133
1243
  }, onEvent);
1134
1244
  }
1135
1245
  catch (err) {
@@ -1146,7 +1256,14 @@ async function runAgentLoop(initialMessages, options) {
1146
1256
  break;
1147
1257
  }
1148
1258
  runSimpleHooks(hooks.OnError, options.workDir);
1149
- throw new Error(`Stream failed: ${err.message}`);
1259
+ // Carry the work already done in this turn out with the error. The loop
1260
+ // owns a COPY of the caller's history, so a plain throw would strand every
1261
+ // completed tool round inside this function and the caller would fall back
1262
+ // to a history containing only the original user message — the user pays
1263
+ // for N minutes of agent work and then has to start over because
1264
+ // "continue" remembers none of it. `messages` always ends on a complete
1265
+ // assistant→tool_result pair here, so it is valid to resend as-is.
1266
+ throw new types_1.AgentTurnError(`Stream failed: ${err.message}`, trimToResumableBoundary(messages), completedRounds, err);
1150
1267
  }
1151
1268
  if (options.abortSignal?.aborted)
1152
1269
  break;
@@ -1393,6 +1510,14 @@ async function runAgentLoop(initialMessages, options) {
1393
1510
  catch (err) {
1394
1511
  result = { error: `Tool execution failed: ${err.message}` };
1395
1512
  }
1513
+ // Opportunistic memory compaction — memory_write is the only tool that can
1514
+ // grow a memory file, so this is the cheapest point to check whether it just
1515
+ // crossed the compaction threshold. Fire-and-forget: never blocks the turn,
1516
+ // never surfaces its own errors to the model (see maybeCompactMemory).
1517
+ if (name === 'memory_write' && result.error === undefined) {
1518
+ const scope = input.scope === 'global' ? 'global' : 'project';
1519
+ void maybeCompactMemory(scope, options);
1520
+ }
1396
1521
  // PostToolUse can inject context for the model or flag a problem.
1397
1522
  const post = runToolHooks(hooks.PostToolUse, 'PostToolUse', name, input, options.workDir, result);
1398
1523
  const injected = [pre.context, post.context].filter(Boolean).join('\n');
@@ -1470,6 +1595,9 @@ async function runAgentLoop(initialMessages, options) {
1470
1595
  content: toolResultContent,
1471
1596
  };
1472
1597
  messages.push(toolResultMessage);
1598
+ // A complete assistant→tool_result pair just landed: the history is valid to
1599
+ // resend from here, so this is exactly one unit of salvageable progress.
1600
+ completedRounds++;
1473
1601
  // Incremental persistence hook: history is valid here (ends on a
1474
1602
  // tool_result user turn). Let the caller checkpoint progress so a crash
1475
1603
  // mid-run loses only the in-flight step, not the whole session.
@@ -1510,9 +1638,69 @@ async function runAgentLoop(initialMessages, options) {
1510
1638
  }
1511
1639
  }
1512
1640
  }
1641
+ catch (err) {
1642
+ // Every other failure path (a tool executor blowing up, a hook throwing, an
1643
+ // out-of-memory in a large read) gets the same treatment as a dead stream:
1644
+ // the turn's completed work leaves with the error instead of dying inside
1645
+ // this function's private copy of the history. Already-tagged errors pass
1646
+ // straight through so we don't double-wrap and lose the original message.
1647
+ if (err instanceof types_1.AgentTurnError)
1648
+ throw err;
1649
+ if (options.abortSignal?.aborted || err.name === 'AbortError')
1650
+ throw err;
1651
+ throw new types_1.AgentTurnError(err?.message || 'The turn ended unexpectedly', trimToResumableBoundary(messages), completedRounds, err);
1652
+ }
1513
1653
  finally {
1514
1654
  runSimpleHooks(hooks.OnStop, options.workDir);
1515
1655
  }
1516
1656
  return messages;
1517
1657
  }
1658
+ /**
1659
+ * Cut a history back to the last point the API will accept as a resumable
1660
+ * prefix, WITHOUT mutating the input.
1661
+ *
1662
+ * Anthropic rejects a conversation whose final assistant turn contains
1663
+ * `tool_use` blocks that never got a matching `tool_result`. The main loop
1664
+ * always appends in complete pairs so it can't produce that shape, but a crash
1665
+ * in the middle of executing a tool round can: the assistant turn was already
1666
+ * pushed, the tool_result carrier never was. Dropping that dangling assistant
1667
+ * turn costs one model response and makes the rest of the (often very long)
1668
+ * history resumable, instead of throwing all of it away.
1669
+ */
1670
+ function trimToResumableBoundary(messages) {
1671
+ // A trailing assistant turn is unresumable if it invoked ANY tool whose result is
1672
+ // missing. That includes server-side invocations (`server_tool_use`, produced by
1673
+ // web search / tool search), which carry a DIFFERENT block type than a client tool
1674
+ // call: matching only 'tool_use' would let a dangling web-search turn through, and
1675
+ // it would 400 on resend exactly like a dangling client call. `chooseFinalContent`
1676
+ // in api/client.ts deliberately preserves these blocks verbatim, so they really do
1677
+ // reach this function.
1678
+ const isUnansweredInvocation = (m) => {
1679
+ if (m?.role !== 'assistant' || !Array.isArray(m.content))
1680
+ return false;
1681
+ const blocks = m.content;
1682
+ // A client tool_use is answered by a tool_result in the NEXT (user) message, which
1683
+ // by definition doesn't exist if this is the last turn — so any of them is dangling.
1684
+ if (blocks.some((b) => b?.type === 'tool_use'))
1685
+ return true;
1686
+ // Server-side invocations are different: Anthropic pairs each `server_tool_use` with
1687
+ // its `*_tool_result` inside this SAME message, so a completed search turn is
1688
+ // perfectly resumable and must not be discarded.
1689
+ //
1690
+ // Pair by id rather than asking "is there any result block at all". A turn cut off
1691
+ // after two searches where only the first returned would otherwise look complete and
1692
+ // 400 on resend — exactly the mid-turn-death case this helper exists for. Matching on
1693
+ // the `_tool_result` suffix also covers result types beyond web/tool search
1694
+ // (code_execution, mcp) without needing to enumerate them.
1695
+ const answeredIds = new Set(blocks.filter((b) => /_tool_result$/.test(b?.type ?? '')).map((b) => b?.tool_use_id));
1696
+ return blocks.some((b) => b?.type === 'server_tool_use' && !answeredIds.has(b?.id));
1697
+ };
1698
+ // Loop rather than a single check: this is an exported helper that accepts any
1699
+ // history, not only the strictly-paired ones the agent loop builds, so consecutive
1700
+ // dangling assistant turns must all be removed for the result to be resendable.
1701
+ let end = messages.length;
1702
+ while (end > 0 && isUnansweredInvocation(messages[end - 1]))
1703
+ end--;
1704
+ return end === messages.length ? messages : messages.slice(0, end);
1705
+ }
1518
1706
  //# sourceMappingURL=loop.js.map
@@ -0,0 +1,45 @@
1
+ export type MemoryScope = 'project' | 'global';
2
+ /** Per-workDir file, keyed the same way CheckpointManager keys its store
3
+ * (sha1 of the resolved absolute path) so two differently-named checkouts of
4
+ * the same repo don't collide, and the same repo always maps to the same file
5
+ * regardless of cwd casing/trailing-slash quirks. */
6
+ export declare function memoryFilePath(scope: MemoryScope, workDir?: string): string;
7
+ export declare const MEMORY_ENTRY_MAX_CHARS = 400;
8
+ export declare const MEMORY_MAX_BYTES = 12000;
9
+ export declare const MEMORY_COMPACT_TRIGGER_BYTES = 16000;
10
+ export declare const MEMORY_HARD_CAP_BYTES: number;
11
+ export interface MemoryWriteResult {
12
+ ok: boolean;
13
+ already: boolean;
14
+ scope: MemoryScope;
15
+ file: string;
16
+ }
17
+ export declare function writeMemory(content: string, scope: MemoryScope, workDir?: string): Promise<MemoryWriteResult>;
18
+ export declare function readMemory(scope: MemoryScope, workDir?: string): string;
19
+ /** Merge BOTH scopes for prompt injection — project-specific first (most
20
+ * relevant to what the agent is doing right now), global preferences after. */
21
+ export declare function readAllMemory(workDir?: string): string;
22
+ export declare function clearMemory(scope: MemoryScope, workDir?: string): void;
23
+ export declare function memoryStats(scope: MemoryScope, workDir?: string): {
24
+ file: string;
25
+ bytes: number;
26
+ entries: number;
27
+ };
28
+ /**
29
+ * Summarize the OLDEST half of a memory file into a few consolidated bullets via
30
+ * an LLM call, keeping the most recent entries verbatim — same "compact, don't
31
+ * silently drop" instinct as the conversation-history auto-compactor, applied to
32
+ * memory instead of a transcript. This is the EXPECTED bounding mechanism for
33
+ * memory file size — called opportunistically (fire-and-forget) right after a
34
+ * successful memory_write (see maybeCompactMemory in loop.ts) once a file crosses
35
+ * MEMORY_COMPACT_TRIGGER_BYTES. writeMemory's own emergency synchronous eviction
36
+ * (MEMORY_HARD_CAP_BYTES) sits well above this threshold specifically so this
37
+ * summarizing pass gets the chance to run first in the common case; the blunt
38
+ * eviction is a last resort for if compaction has been failing/unavailable.
39
+ *
40
+ * `summarize` is injected (rather than importing streamChat directly) to avoid
41
+ * pulling api/client.ts's network/auth machinery into every consumer of this
42
+ * module and to keep this file trivially unit-testable.
43
+ */
44
+ export declare function compactMemoryIfNeeded(scope: MemoryScope, workDir: string | undefined, summarize: (prompt: string) => Promise<string>): Promise<boolean>;
45
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/agent/memory.ts"],"names":[],"mappings":"AAiCA,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE/C;;;sDAGsD;AACtD,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3E;AAED,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,eAAO,MAAM,gBAAgB,QAAS,CAAC;AACvC,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAMnD,eAAO,MAAM,qBAAqB,QAAmC,CAAC;AA4CtE,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAuBnH;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAGvE;AAED;gFACgF;AAChF,wBAAgB,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAStD;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAGtE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAKlH;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAC7C,OAAO,CAAC,OAAO,CAAC,CA+BlB"}