@mikode13/harness 11.1.0 → 11.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 (69) hide show
  1. package/README.md +48 -10
  2. package/dist/agent/domain/agent.d.ts +17 -0
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/runContext.d.ts +5 -0
  5. package/dist/agent/domain/runContext.js.map +1 -1
  6. package/dist/engines/domain/model/compactor.d.ts +40 -0
  7. package/dist/engines/domain/model/compactor.js +5 -0
  8. package/dist/engines/domain/model/compactor.js.map +1 -0
  9. package/dist/engines/domain/model/llmAgent.d.ts +48 -2
  10. package/dist/engines/domain/model/llmAgent.js +351 -14
  11. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  12. package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
  13. package/dist/engines/domain/model/recentCompaction.js +119 -0
  14. package/dist/engines/domain/model/recentCompaction.js.map +1 -0
  15. package/dist/engines/domain/model/stepContext.js +3 -0
  16. package/dist/engines/domain/model/stepContext.js.map +1 -1
  17. package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
  18. package/dist/engines/domain/model/triagedCompaction.js +60 -0
  19. package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
  20. package/dist/factory/infrastructure/agentLLMFactory.d.ts +40 -12
  21. package/dist/factory/infrastructure/agentLLMFactory.js +114 -18
  22. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/llm/domain/contextTriage.d.ts +26 -0
  26. package/dist/llm/domain/contextTriage.js +3 -0
  27. package/dist/llm/domain/contextTriage.js.map +1 -0
  28. package/dist/llm/domain/llm.d.ts +18 -1
  29. package/dist/llm/domain/llm.js.map +1 -1
  30. package/dist/llm/domain/message.d.ts +18 -1
  31. package/dist/llm/domain/message.js +20 -0
  32. package/dist/llm/domain/message.js.map +1 -1
  33. package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
  34. package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
  35. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  36. package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
  37. package/dist/llm/infrastructure/jevContextTriage.js +92 -0
  38. package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
  39. package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
  40. package/dist/llm/infrastructure/openAILLMClient.js +20 -9
  41. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  42. package/dist/tools/domain/preparedCall.d.ts +2 -1
  43. package/dist/tools/domain/preparedCall.js +7 -1
  44. package/dist/tools/domain/preparedCall.js.map +1 -1
  45. package/dist/tools/domain/readRegistry.d.ts +11 -0
  46. package/dist/tools/domain/readRegistry.js.map +1 -1
  47. package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
  48. package/dist/tools/infrastructure/agentAsTool.js +9 -0
  49. package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
  50. package/dist/tools/infrastructure/commandTool.js +4 -1
  51. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  52. package/dist/tools/infrastructure/exploreTool.js +2 -0
  53. package/dist/tools/infrastructure/exploreTool.js.map +1 -1
  54. package/dist/tools/infrastructure/fileTools.js +1 -0
  55. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  56. package/dist/tools/infrastructure/removeWorktreeTool.js +7 -1
  57. package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -1
  58. package/dist/tools/infrastructure/subagents.d.ts +3 -1
  59. package/dist/tools/infrastructure/subagents.js +2 -1
  60. package/dist/tools/infrastructure/subagents.js.map +1 -1
  61. package/dist/tools/infrastructure/trackedEdits.js +1 -0
  62. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  63. package/dist/tools/infrastructure/workspaceWrites.d.ts +13 -1
  64. package/dist/tools/infrastructure/workspaceWrites.js +18 -1
  65. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  66. package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
  67. package/dist/tools/infrastructure/writeCeiling.js +49 -0
  68. package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
  69. package/package.json +2 -1
@@ -4,6 +4,9 @@ import { delegationOf, RunContext, runContextOf, withRunContext, } from '#src/ag
4
4
  import { InvalidAgentConfigError, RecoverableError, UnrecoverableError, withSpentTokens, } from '#src/shared/domain/errors';
5
5
  import { classifyHostFailure, classifyLocalFailure, classifyProviderFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
6
6
  import { Conversation } from '#src/llm/domain/conversation';
7
+ import { keptScore } from '#src/llm/domain/contextTriage';
8
+ import { MaxContextError } from '#src/llm/domain/errors';
9
+ import { compactedContext, } from '#src/llm/domain/message';
7
10
  import { asNative, nativeOf } from '#src/llm/domain/tool';
8
11
  import { readAnswers } from '#src/agent/domain/question';
9
12
  import { readerOf } from '#src/agent/domain/inbox';
@@ -16,6 +19,9 @@ import { isAbortError } from '#src/shared/domain/isAbortError';
16
19
  import { addTokens } from '#src/shared/domain/tokens';
17
20
  import { anotherAttempt, askAt, CommandLoops, extensionNote, loopQuestion, loopStop, loopWarning, repeatedAnswersAt, repeatNote, repeatQuestion, repeatStop, stop, warnAt, } from "./commandLoops.js";
18
21
  import { BackgroundAgents, sentNote } from "./backgroundAgents.js";
22
+ import { compactionOff } from "./compactor.js";
23
+ import { messagesAsText, recentCut, recentlyKept, summaryRequest, transcriptSummaryRequest, } from "./recentCompaction.js";
24
+ import { triageCandidates, triagedCut, triagedKept } from "./triagedCompaction.js";
19
25
  import { measureContext } from "./stepContext.js";
20
26
  import { askUserDefinition, askUserName, describeAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
21
27
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
@@ -41,8 +47,25 @@ function describePart(part) {
41
47
  case 'compaction':
42
48
  // The provider's, for its client to send back; the step's context shows its effect.
43
49
  return undefined;
50
+ case 'contextSummary':
51
+ // Narrated as a `compaction` event when it is written.
52
+ return undefined;
44
53
  }
45
54
  }
55
+ /** The tokens of context a call sent: all its input, cached or not. */
56
+ function contextSize(tokens) {
57
+ return tokens.inputTokens + tokens.readCacheTokens + tokens.writtenCacheTokens;
58
+ }
59
+ /**
60
+ * `message` with each summary in it reduced to its text: what a summary kept is in the
61
+ * conversation before it already.
62
+ */
63
+ function withoutKeptCopies(message) {
64
+ return {
65
+ role: message.role,
66
+ content: message.content.map(part => part.type === 'contextSummary' ? { type: part.type, summary: part.summary } : part),
67
+ };
68
+ }
46
69
  function narrate(part, callback, summary, exitCode) {
47
70
  const event = describePart(part);
48
71
  if (!event)
@@ -151,6 +174,12 @@ export class LLMAgent {
151
174
  history;
152
175
  summarizer;
153
176
  onTranscript;
177
+ writeRoots;
178
+ compactor;
179
+ // The tokens of context the model was last sent in a run that was kept, as its provider
180
+ // reported them, which the recent compactor measures against its threshold: the
181
+ // conversation's size, not a run's count.
182
+ contextTokens;
154
183
  // The latest move through the history this conversation has followed.
155
184
  seenRevision = 0;
156
185
  // What this conversation has seen of each file, so an edit starts from a version it read.
@@ -158,10 +187,21 @@ export class LLMAgent {
158
187
  runs = new OneRunAtATime();
159
188
  // The session its runs belong to when their options name none.
160
189
  session = newSession();
161
- constructor({ llmClient, messages, tools = [], maxSteps = 65, autoApprove = false, logger, history, summarizer, onTranscript, }) {
190
+ constructor({ llmClient, messages, tools = [], maxSteps = 65, autoApprove = false, logger, history, summarizer, onTranscript, writeRoots, compactor = compactionOff, }) {
162
191
  if (!Number.isInteger(maxSteps) || maxSteps < 1) {
163
192
  throw new InvalidAgentConfigError(`maxSteps must be a positive integer; got ${String(maxSteps)}`);
164
193
  }
194
+ if (compactor.by === 'harness') {
195
+ if (compactor.at !== undefined && !(Number.isInteger(compactor.at) && compactor.at > 0)) {
196
+ throw new InvalidAgentConfigError(`compactAt must be a positive whole number of tokens, not ${String(compactor.at)}`);
197
+ }
198
+ }
199
+ else if (compactor.at !== undefined) {
200
+ if (!llmClient.checkCompactAt) {
201
+ throw new InvalidAgentConfigError('This model client cannot compact; leave compactAt out');
202
+ }
203
+ llmClient.checkCompactAt(compactor.at);
204
+ }
165
205
  this.llmClient = llmClient;
166
206
  this.conversation = new Conversation(messages);
167
207
  this.maxSteps = maxSteps;
@@ -170,6 +210,8 @@ export class LLMAgent {
170
210
  this.history = history;
171
211
  this.summarizer = summarizer;
172
212
  this.onTranscript = onTranscript;
213
+ this.writeRoots = writeRoots;
214
+ this.compactor = compactor;
173
215
  this.tools = new Map(tools.map(tool => [tool.name, tool]));
174
216
  if (this.tools.size !== tools.length) {
175
217
  // The model calls tools by name, so a second one with the same name could never run.
@@ -243,12 +285,206 @@ export class LLMAgent {
243
285
  this.logger?.warn(`The summary of the undone turns failed: ${why}`);
244
286
  }, classifyHostFailure, 'LLM agent logger failed');
245
287
  }
246
- async llmCall(runMessages, after, { signal, step, callback, tools, }) {
288
+ warnContextOutgrown() {
289
+ treatErrors(() => {
290
+ this.logger?.warn('The context no longer fits the model; the next call compacts it and tries again');
291
+ }, classifyHostFailure, 'LLM agent logger failed');
292
+ }
293
+ warnTriageFailed(why) {
294
+ treatErrors(() => {
295
+ this.logger?.warn(`Jev could not choose what to keep, so the recent compactor did: ${why}`);
296
+ }, classifyHostFailure, 'LLM agent logger failed');
297
+ }
298
+ warnCompactionFailed(why) {
299
+ treatErrors(() => {
300
+ this.logger?.warn(`The context could not be compacted: ${why}`);
301
+ }, classifyHostFailure, 'LLM agent logger failed');
302
+ }
303
+ /**
304
+ * Warns that a summary the model was called for failed. A logger that throws ends the run,
305
+ * carrying what that call spent, which the run has not counted yet.
306
+ */
307
+ warnSummaryCallFailed(why, { usage, unreported }) {
308
+ try {
309
+ this.warnCompactionFailed(why);
310
+ }
311
+ catch (hostFailure) {
312
+ throw withSpentTokens(hostFailure, usage, unreported);
313
+ }
314
+ }
315
+ /**
316
+ * Has the agent's own model summarize the context before the harness compactor's cut, and
317
+ * records the summary in the run, with what it keeps word for word. With a triage, Jev chooses
318
+ * what is kept; when it fails, which it logs, the recent cut does. `compacted` is false when
319
+ * the model had answered too few times, which costs no call, when Jev kept every answer
320
+ * whole, unless the context no longer fits, or when the summary failed as `summarize` says.
321
+ */
322
+ async compactHarness(compactor, runMessages, after, options) {
323
+ // The run's messages are not copied, so its prompt is found where it is.
324
+ const context = compactedContext([...this.conversation.contextAfter(after), ...runMessages]);
325
+ const [prompt] = runMessages;
326
+ let chosen = prompt && (await this.harnessCut(compactor, context, prompt, options.signal));
327
+ // A context that no longer fits must lose something: when Jev's cut keeps it all, even the
328
+ // last answer it always keeps, recent cuts.
329
+ if (prompt && compactor.triage && !chosen?.cut && options.outgrown) {
330
+ chosen = this.recentChoice(compactor, context, prompt);
331
+ }
332
+ if (!chosen?.cut) {
333
+ const why = chosen?.triaged
334
+ ? 'Jev kept every answer whole, so there is nothing to summarize'
335
+ : 'the model has not answered yet, so there is nothing to summarize';
336
+ // A context that no longer fits ends the run, with why in its error rather than the log.
337
+ if (!options.outgrown)
338
+ this.warnCompactionFailed(why);
339
+ // Jev would keep the same again: it is not asked again in the same run.
340
+ return {
341
+ compacted: false,
342
+ called: chosen?.triaged ?? false,
343
+ usage: undefined,
344
+ unreported: false,
345
+ why,
346
+ };
347
+ }
348
+ // The run's context no longer fitting is what makes a failure end it.
349
+ return this.summarize(chosen.cut, chosen.kept, runMessages, {
350
+ ...options,
351
+ context,
352
+ endsRun: options.outgrown,
353
+ });
354
+ }
355
+ /**
356
+ * Where the harness compactor cuts `context`, and what it keeps, as the summary request says
357
+ * it. `triaged` when Jev was asked and answered.
358
+ */
359
+ async harnessCut(compactor, context, prompt, signal) {
360
+ const { triage } = compactor;
361
+ const candidates = triage ? triageCandidates(context) : [];
362
+ let scores = triage && candidates.length === 0 ? [] : undefined;
363
+ if (triage && candidates.length > 0) {
364
+ const latest = context.findLast(message => message.role === 'assistant');
365
+ try {
366
+ scores = await triage.score({
367
+ task: prompt.content
368
+ .flatMap(part => (part.type === 'text' ? [part.text] : []))
369
+ .join('\n'),
370
+ latestAnswer: latest ? messagesAsText([latest], 8_000, 3_000) : '',
371
+ results: candidates.map(({ tool, input, output }) => ({ tool, input, output })),
372
+ }, signal);
373
+ }
374
+ catch (error) {
375
+ if (isAbortError(error))
376
+ throw error;
377
+ signal.throwIfAborted();
378
+ this.warnTriageFailed(describeFailure(error));
379
+ }
380
+ }
381
+ if (scores) {
382
+ const kept = candidates.filter((_, index) => (scores[index] ?? 0) >= keptScore);
383
+ return {
384
+ cut: triagedCut(context, prompt, new Set(kept.map(({ callId }) => callId))),
385
+ kept: triagedKept(kept),
386
+ triaged: candidates.length > 0,
387
+ };
388
+ }
389
+ return this.recentChoice(compactor, context, prompt);
390
+ }
391
+ recentChoice(compactor, context, prompt) {
392
+ return {
393
+ cut: recentCut(context, prompt, compactor.keptSteps),
394
+ kept: recentlyKept(compactor.keptSteps),
395
+ triaged: false,
396
+ };
397
+ }
398
+ /**
399
+ * Has the agent's own model summarize what `cut` does not keep, and records the summary in the
400
+ * run. Once the threshold passed, the model reads `context`, the context as it was last sent,
401
+ * from its provider's cache; once the context no longer fit, it reads what it summarizes as
402
+ * text. `compacted` is false when the model wrote no summary or the provider failed in
403
+ * passing, which it logs; any other failure is thrown. The summary's usage is the run's either
404
+ * way.
405
+ *
406
+ * @param kept what `cut` keeps, as the summary request tells the model.
407
+ * @param endsRun whether a failure ends the run, which throws why instead of logging it: the
408
+ * run's context no longer fits. `outgrown` alone may be this call's own fallback.
409
+ */
410
+ async summarize(cut, kept, runMessages, { context, signal, tools, outgrown, endsRun, }) {
411
+ const request = outgrown
412
+ ? { context: [transcriptSummaryRequest(cut.summarized, kept)], tools: [] }
413
+ : { context: [...structuredClone(context), summaryRequest(kept)], tools };
414
+ let response;
415
+ try {
416
+ response = await this.llmClient.send(request, signal);
417
+ }
418
+ catch (error) {
419
+ if (isAbortError(error))
420
+ throw error;
421
+ const failure = classifyProviderFailure(error, 'The context summary failed');
422
+ if (failure instanceof MaxContextError && !outgrown) {
423
+ // The context grew past the model since it was last sent: the model reads what it
424
+ // summarizes as text instead, and this call is the run's too.
425
+ let fallback;
426
+ try {
427
+ fallback = await this.summarize(cut, kept, runMessages, {
428
+ context,
429
+ signal,
430
+ tools,
431
+ outgrown: true,
432
+ endsRun,
433
+ });
434
+ }
435
+ catch (fallbackFailure) {
436
+ throw withSpentTokens(fallbackFailure, failure.tokens, failure.usageUnreported);
437
+ }
438
+ return {
439
+ ...fallback,
440
+ usage: addTokens(failure.tokens, fallback.usage),
441
+ unreported: fallback.unreported || failure.usageUnreported,
442
+ };
443
+ }
444
+ // Only a passing failure of the provider goes without a summary: anything else, such as
445
+ // a logger or a response the client could not map, ends the run as any call's would.
446
+ if (!(failure instanceof RecoverableError))
447
+ throw failure;
448
+ const failed = {
449
+ compacted: false,
450
+ called: true,
451
+ usage: failure.tokens,
452
+ unreported: failure.usageUnreported,
453
+ why: describeFailure(failure),
454
+ };
455
+ if (!endsRun)
456
+ this.warnSummaryCallFailed(failed.why, failed);
457
+ return failed;
458
+ }
459
+ const spent = { called: true, usage: response.usage, unreported: !response.usage };
460
+ const summary = response.message.content
461
+ .flatMap(part => (part.type === 'text' ? [part.text] : []))
462
+ .join('\n')
463
+ .trim();
464
+ // A cut or refused answer, or one that calls a tool, is no summary of the work.
465
+ if (response.stopReason !== 'completed' ||
466
+ response.message.content.some(part => part.type === 'toolCall') ||
467
+ summary === '') {
468
+ const why = 'the model did not answer with a summary';
469
+ if (!endsRun)
470
+ this.warnSummaryCallFailed(why, spent);
471
+ return { compacted: false, ...spent, why };
472
+ }
473
+ runMessages.push({
474
+ role: 'user',
475
+ content: [{ type: 'contextSummary', summary, kept: cut.kept }],
476
+ });
477
+ return { compacted: true, ...spent };
478
+ }
479
+ async llmCall(runMessages, after, { signal, step, callback, tools, compactAt, }) {
247
480
  // Sent as a copy, so what the run records is what the agent built, whatever the client does.
248
- const context = [...this.conversation.contextAfter(after), ...structuredClone(runMessages)];
481
+ const context = compactedContext([
482
+ ...this.conversation.contextAfter(after),
483
+ ...structuredClone(runMessages),
484
+ ]);
249
485
  let response;
250
486
  try {
251
- response = await this.llmClient.send({ context, tools }, signal);
487
+ response = await this.llmClient.send({ context, tools, compactAt }, signal);
252
488
  }
253
489
  catch (error) {
254
490
  const failure = classifyProviderFailure(error, 'The LLM call failed');
@@ -701,7 +937,7 @@ export class LLMAgent {
701
937
  description,
702
938
  inputSchema,
703
939
  })),
704
- messages: seen.messages(),
940
+ messages: seen.messages().map(withoutKeptCopies),
705
941
  ...(outcome === 'completed' ? {} : { error: describeFailure(error) }),
706
942
  });
707
943
  await this.onTranscript({
@@ -726,6 +962,7 @@ export class LLMAgent {
726
962
  }
727
963
  async takeSteps(prompt, options, context, seen) {
728
964
  const { signal, onProgress: callback = ignoreProgress } = options;
965
+ const delegation = delegationOf(options);
729
966
  // A tool narrates through this without ever failing: a failing callback would reach
730
967
  // only the tool, which turns any failure into a result for the model. The run ends on
731
968
  // it once that tool returns.
@@ -767,15 +1004,22 @@ export class LLMAgent {
767
1004
  // The turn this one follows: the last recorded, unless the workspace moved since.
768
1005
  let after = this.conversation.currentTurn;
769
1006
  let followed;
770
- // What the model has seen so far, whichever turn the run ends up following.
1007
+ // The context's size as this run knows it, the agent's once the run is kept: a run that
1008
+ // is not kept leaves the conversation, and so its size, as they were.
1009
+ let contextTokens = this.contextTokens;
1010
+ // What the model has seen so far, whichever turn the run ends up following: every message,
1011
+ // those a summary replaced too, for a person debugging the run.
771
1012
  seen.messages = () => [...this.conversation.contextAfter(after), ...runMessages];
772
1013
  try {
773
1014
  if (this.history) {
774
1015
  followed = await this.followHistory(this.history);
775
1016
  after = followed.turn;
776
1017
  // The files may no longer hold what the conversation saw of them.
777
- if (followed.revisions.length > 0)
1018
+ if (followed.revisions.length > 0) {
778
1019
  this.reads.clear();
1020
+ // Its context is no longer the one last sent.
1021
+ contextTokens = undefined;
1022
+ }
779
1023
  if (followed.undone.length > 0) {
780
1024
  const summary = await this.summarizeUndone(followed, signal);
781
1025
  tokens = addTokens(tokens, summary.usage);
@@ -820,6 +1064,7 @@ export class LLMAgent {
820
1064
  reads.commit();
821
1065
  if (followed)
822
1066
  this.seenRevision = followed.latestRevision;
1067
+ this.contextTokens = contextTokens;
823
1068
  };
824
1069
  // A run kept although it failed stops its agents first, and tells the next run what
825
1070
  // became of each one, which would otherwise wait for answers that never come.
@@ -830,20 +1075,110 @@ export class LLMAgent {
830
1075
  runMessages.push(ended);
831
1076
  remember();
832
1077
  };
1078
+ // Whether the context outgrew the model at the last call, so this one compacts it first,
1079
+ // and how large it was, when the provider said.
1080
+ let outgrown = false;
1081
+ let outgrownTokens;
1082
+ // A summary the model failed to write is not asked for again in the same run, unless the
1083
+ // context no longer fits.
1084
+ let summaryFailed = false;
1085
+ // A summary was just written, so the next call measures what it left.
1086
+ let summarized = false;
1087
+ const { compactor } = this;
833
1088
  for (let step = 1; step <= this.maxSteps; step++) {
834
1089
  // An agent at work may have narrated through a callback that failed.
835
1090
  throwIfProgressFailed();
836
- const { response, context: sent } = await this.llmCall(runMessages, after, {
837
- signal,
838
- step,
839
- callback,
840
- tools,
841
- });
1091
+ if (compactor.by === 'harness' &&
1092
+ (outgrown ||
1093
+ (!summaryFailed &&
1094
+ compactor.at !== undefined &&
1095
+ contextTokens !== undefined &&
1096
+ contextTokens >= compactor.at))) {
1097
+ const compaction = await this.compactHarness(compactor, runMessages, after, {
1098
+ signal,
1099
+ tools,
1100
+ outgrown,
1101
+ });
1102
+ tokens = addTokens(tokens, compaction.usage);
1103
+ unreported ||= compaction.unreported;
1104
+ if (compaction.compacted) {
1105
+ const compacted = outgrown ? outgrownTokens : contextTokens;
1106
+ contextTokens = undefined;
1107
+ summarized = true;
1108
+ emit({
1109
+ type: 'compaction',
1110
+ by: 'harness',
1111
+ reason: outgrown ? 'maxContext' : 'threshold',
1112
+ ...(compacted === undefined ? {} : { contextTokens: compacted }),
1113
+ }, callback);
1114
+ }
1115
+ else if (outgrown) {
1116
+ throw new MaxContextError('The context no longer fits the model', {
1117
+ cause: `It no longer fit, and could not be compacted: ${compaction.why ?? 'it is unknown why'}.`,
1118
+ });
1119
+ }
1120
+ else {
1121
+ summaryFailed = compaction.called;
1122
+ }
1123
+ // An agent at work may have ended the run while the summary was written.
1124
+ throwIfProgressFailed();
1125
+ }
1126
+ let called;
1127
+ try {
1128
+ called = await this.llmCall(runMessages, after, {
1129
+ signal,
1130
+ step,
1131
+ callback,
1132
+ tools,
1133
+ // The harness's own compactor leaves the provider's off.
1134
+ compactAt: compactor.by === 'harness' ? undefined : outgrown ? 'now' : compactor.at,
1135
+ });
1136
+ }
1137
+ catch (error) {
1138
+ // Compacted once and tried again, even with compaction off: a second time in a row,
1139
+ // with nothing to compact it, or no step left to try ends the run.
1140
+ if (!(error instanceof MaxContextError) ||
1141
+ outgrown ||
1142
+ (compactor.by === 'provider' && !this.llmClient.checkCompactAt) ||
1143
+ step === this.maxSteps) {
1144
+ throw error;
1145
+ }
1146
+ // Billed, and already narrated as a step.
1147
+ tokens = addTokens(tokens, error.tokens);
1148
+ unreported ||= error.usageUnreported;
1149
+ this.warnContextOutgrown();
1150
+ outgrown = true;
1151
+ outgrownTokens = error.tokens && contextSize(error.tokens);
1152
+ continue;
1153
+ }
1154
+ const { response, context: sent } = called;
842
1155
  tokens = addTokens(tokens, response.usage);
843
1156
  unreported ||= !response.usage;
1157
+ contextTokens = response.usage && contextSize(response.usage);
1158
+ // What a summary keeps word for word may stay past the threshold: summarizing it again
1159
+ // on every step would cost a whole context each time and keep nothing less.
1160
+ if (summarized &&
1161
+ compactor.at !== undefined &&
1162
+ contextTokens !== undefined &&
1163
+ contextTokens >= compactor.at) {
1164
+ summaryFailed = true;
1165
+ this.warnCompactionFailed(`what the summary kept is still ${String(contextTokens)} tokens, past the threshold of ${String(compactor.at)}, so the context is not compacted again in this run unless it no longer fits`);
1166
+ }
1167
+ summarized = false;
844
1168
  // After the tokens are counted, so a throwing callback still reports them, and for an
845
1169
  // unusable answer too: the call was sent, and billed.
846
1170
  this.narrateStep(step, response.usage, { context: sent, tools }, callback);
1171
+ if (response.message.content.some(part => part.type === 'compaction')) {
1172
+ emit({
1173
+ type: 'compaction',
1174
+ by: 'provider',
1175
+ reason: outgrown ? 'maxContext' : 'threshold',
1176
+ ...(response.compactedContextTokens === undefined
1177
+ ? {}
1178
+ : { contextTokens: response.compactedContextTokens }),
1179
+ }, callback);
1180
+ }
1181
+ outgrown = false;
847
1182
  throwIfProgressFailed();
848
1183
  if (response.stopReason === 'truncated') {
849
1184
  // What it wrote before the output limit, kept with the run so the next run can
@@ -950,7 +1285,9 @@ export class LLMAgent {
950
1285
  reads,
951
1286
  onProgress,
952
1287
  spend,
953
- depth: delegationOf(options)?.depth ?? 0,
1288
+ depth: delegation?.depth ?? 0,
1289
+ ...(delegation?.writeCeiling ? { writeCeiling: delegation.writeCeiling } : {}),
1290
+ ...(this.writeRoots ? { agentWriteRoots: this.writeRoots } : {}),
954
1291
  ...(options.approve ? { approve: options.approve } : {}),
955
1292
  ...(options.ask ? { ask: options.ask } : {}),
956
1293
  }, throwIfProgressFailed, loops, background);