@mikode13/harness 11.0.1 → 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 (120) hide show
  1. package/README.md +186 -16
  2. package/dist/agent/domain/agent.d.ts +41 -3
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/approval.d.ts +20 -0
  5. package/dist/agent/domain/approval.js.map +1 -1
  6. package/dist/agent/domain/inbox.d.ts +42 -0
  7. package/dist/agent/domain/inbox.js +81 -0
  8. package/dist/agent/domain/inbox.js.map +1 -0
  9. package/dist/agent/domain/oneRunAtATime.d.ts +17 -0
  10. package/dist/agent/domain/oneRunAtATime.js +32 -0
  11. package/dist/agent/domain/oneRunAtATime.js.map +1 -0
  12. package/dist/agent/domain/runContext.d.ts +9 -0
  13. package/dist/agent/domain/runContext.js +5 -0
  14. package/dist/agent/domain/runContext.js.map +1 -1
  15. package/dist/agent/domain/session.d.ts +15 -0
  16. package/dist/agent/domain/session.js +80 -0
  17. package/dist/agent/domain/session.js.map +1 -0
  18. package/dist/engines/claude/infrastructure/model/claudeAgent.d.ts +6 -2
  19. package/dist/engines/claude/infrastructure/model/claudeAgent.js +20 -5
  20. package/dist/engines/claude/infrastructure/model/claudeAgent.js.map +1 -1
  21. package/dist/engines/codex/infrastructure/model/codexAgent.d.ts +5 -1
  22. package/dist/engines/codex/infrastructure/model/codexAgent.js +13 -2
  23. package/dist/engines/codex/infrastructure/model/codexAgent.js.map +1 -1
  24. package/dist/engines/domain/model/backgroundAgents.d.ts +5 -2
  25. package/dist/engines/domain/model/backgroundAgents.js +11 -3
  26. package/dist/engines/domain/model/backgroundAgents.js.map +1 -1
  27. package/dist/engines/domain/model/compactor.d.ts +40 -0
  28. package/dist/engines/domain/model/compactor.js +5 -0
  29. package/dist/engines/domain/model/compactor.js.map +1 -0
  30. package/dist/engines/domain/model/llmAgent.d.ts +53 -2
  31. package/dist/engines/domain/model/llmAgent.js +419 -24
  32. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  33. package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
  34. package/dist/engines/domain/model/recentCompaction.js +119 -0
  35. package/dist/engines/domain/model/recentCompaction.js.map +1 -0
  36. package/dist/engines/domain/model/stepContext.js +3 -0
  37. package/dist/engines/domain/model/stepContext.js.map +1 -1
  38. package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
  39. package/dist/engines/domain/model/triagedCompaction.js +60 -0
  40. package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
  41. package/dist/factory/infrastructure/agentLLMFactory.d.ts +56 -15
  42. package/dist/factory/infrastructure/agentLLMFactory.js +144 -25
  43. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  44. package/dist/index.d.ts +6 -2
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/llm/domain/contextTriage.d.ts +26 -0
  48. package/dist/llm/domain/contextTriage.js +3 -0
  49. package/dist/llm/domain/contextTriage.js.map +1 -0
  50. package/dist/llm/domain/llm.d.ts +18 -1
  51. package/dist/llm/domain/llm.js.map +1 -1
  52. package/dist/llm/domain/message.d.ts +18 -1
  53. package/dist/llm/domain/message.js +20 -0
  54. package/dist/llm/domain/message.js.map +1 -1
  55. package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
  56. package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
  57. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  58. package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
  59. package/dist/llm/infrastructure/jevContextTriage.js +92 -0
  60. package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
  61. package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
  62. package/dist/llm/infrastructure/openAILLMClient.js +20 -9
  63. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  64. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +9 -1
  65. package/dist/orchestration/domain/model/orchestratorAgent.js +20 -3
  66. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  67. package/dist/recovery/domain/history.d.ts +10 -3
  68. package/dist/recovery/domain/history.js +1 -0
  69. package/dist/recovery/domain/history.js.map +1 -1
  70. package/dist/recovery/domain/recoveryStore.d.ts +14 -2
  71. package/dist/recovery/domain/recoveryStore.js.map +1 -1
  72. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +21 -2
  73. package/dist/recovery/infrastructure/fileRecoveryStore.js +58 -6
  74. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
  75. package/dist/recovery/infrastructure/folderClaims.d.ts +27 -0
  76. package/dist/recovery/infrastructure/folderClaims.js +124 -0
  77. package/dist/recovery/infrastructure/folderClaims.js.map +1 -0
  78. package/dist/shared/domain/session.d.ts +12 -0
  79. package/dist/shared/domain/session.js +2 -0
  80. package/dist/shared/domain/session.js.map +1 -0
  81. package/dist/tools/domain/preparedCall.d.ts +5 -2
  82. package/dist/tools/domain/preparedCall.js +7 -1
  83. package/dist/tools/domain/preparedCall.js.map +1 -1
  84. package/dist/tools/domain/readRegistry.d.ts +11 -0
  85. package/dist/tools/domain/readRegistry.js.map +1 -1
  86. package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
  87. package/dist/tools/infrastructure/agentAsTool.js +9 -0
  88. package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
  89. package/dist/tools/infrastructure/commandTool.js +6 -1
  90. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  91. package/dist/tools/infrastructure/exploreTool.js +2 -0
  92. package/dist/tools/infrastructure/exploreTool.js.map +1 -1
  93. package/dist/tools/infrastructure/fileTools.d.ts +2 -0
  94. package/dist/tools/infrastructure/fileTools.js +10 -1
  95. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  96. package/dist/tools/infrastructure/removeWorktreeTool.d.ts +23 -0
  97. package/dist/tools/infrastructure/removeWorktreeTool.js +178 -0
  98. package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -0
  99. package/dist/tools/infrastructure/subagents.d.ts +3 -1
  100. package/dist/tools/infrastructure/subagents.js +2 -1
  101. package/dist/tools/infrastructure/subagents.js.map +1 -1
  102. package/dist/tools/infrastructure/trackedEdits.js +1 -0
  103. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  104. package/dist/tools/infrastructure/workspaceWrites.d.ts +17 -1
  105. package/dist/tools/infrastructure/workspaceWrites.js +48 -17
  106. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  107. package/dist/tools/infrastructure/worktrees.d.ts +49 -0
  108. package/dist/tools/infrastructure/worktrees.js +154 -0
  109. package/dist/tools/infrastructure/worktrees.js.map +1 -0
  110. package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
  111. package/dist/tools/infrastructure/writeCeiling.js +49 -0
  112. package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
  113. package/dist/tools/infrastructure/writeSession.d.ts +5 -1
  114. package/dist/tools/infrastructure/writeSession.js +7 -3
  115. package/dist/tools/infrastructure/writeSession.js.map +1 -1
  116. package/dist/workflow/domain/workflow.d.ts +3 -0
  117. package/dist/workflow/domain/workflow.js.map +1 -1
  118. package/dist/workflow/domain/workflowRunner.js +8 -2
  119. package/dist/workflow/domain/workflowRunner.js.map +1 -1
  120. package/package.json +2 -1
@@ -4,8 +4,14 @@ 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';
12
+ import { readerOf } from '#src/agent/domain/inbox';
13
+ import { OneRunAtATime } from '#src/agent/domain/oneRunAtATime';
14
+ import { newSession, sessionOf } from '#src/agent/domain/session';
9
15
  import { isDelegating, prepareCall, RunEndingToolError, ToolExecutionError, } from '#src/tools/domain/preparedCall';
10
16
  import { ReadRegistry } from '#src/tools/domain/readRegistry';
11
17
  import { InvalidToolInputError } from '#src/tools/domain/tool';
@@ -13,6 +19,9 @@ import { isAbortError } from '#src/shared/domain/isAbortError';
13
19
  import { addTokens } from '#src/shared/domain/tokens';
14
20
  import { anotherAttempt, askAt, CommandLoops, extensionNote, loopQuestion, loopStop, loopWarning, repeatedAnswersAt, repeatNote, repeatQuestion, repeatStop, stop, warnAt, } from "./commandLoops.js";
15
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";
16
25
  import { measureContext } from "./stepContext.js";
17
26
  import { askUserDefinition, askUserName, describeAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
18
27
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
@@ -38,8 +47,25 @@ function describePart(part) {
38
47
  case 'compaction':
39
48
  // The provider's, for its client to send back; the step's context shows its effect.
40
49
  return undefined;
50
+ case 'contextSummary':
51
+ // Narrated as a `compaction` event when it is written.
52
+ return undefined;
41
53
  }
42
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
+ }
43
69
  function narrate(part, callback, summary, exitCode) {
44
70
  const event = describePart(part);
45
71
  if (!event)
@@ -117,6 +143,16 @@ function withNote(message, note) {
117
143
  content: [...message.content.slice(0, -1), { ...last, output: `${last.output}\n\n${note}` }],
118
144
  };
119
145
  }
146
+ // What the model is told of a message from its inbox, ahead of the message itself.
147
+ const sentByUser = 'The user sent this while you worked:';
148
+ /** What the user sent since the run last looked, and the message the model reads it in. */
149
+ function takeInbox(inbox) {
150
+ const texts = inbox?.take() ?? [];
151
+ if (texts.length === 0)
152
+ return undefined;
153
+ const content = [sentByUser, ...texts].map(text => ({ type: 'text', text }));
154
+ return { texts, message: { role: 'user', content } };
155
+ }
120
156
  /**
121
157
  * An agent whose conversation is owned by MiKode: every run sends the whole context to a
122
158
  * stateless `LLMClient`. Within a run the model may call the agent's tools; the agent runs them
@@ -138,14 +174,34 @@ export class LLMAgent {
138
174
  history;
139
175
  summarizer;
140
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;
141
183
  // The latest move through the history this conversation has followed.
142
184
  seenRevision = 0;
143
185
  // What this conversation has seen of each file, so an edit starts from a version it read.
144
186
  reads = new ReadRegistry();
145
- constructor({ llmClient, messages, tools = [], maxSteps = 65, autoApprove = false, logger, history, summarizer, onTranscript, }) {
187
+ runs = new OneRunAtATime();
188
+ // The session its runs belong to when their options name none.
189
+ session = newSession();
190
+ constructor({ llmClient, messages, tools = [], maxSteps = 65, autoApprove = false, logger, history, summarizer, onTranscript, writeRoots, compactor = compactionOff, }) {
146
191
  if (!Number.isInteger(maxSteps) || maxSteps < 1) {
147
192
  throw new InvalidAgentConfigError(`maxSteps must be a positive integer; got ${String(maxSteps)}`);
148
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
+ }
149
205
  this.llmClient = llmClient;
150
206
  this.conversation = new Conversation(messages);
151
207
  this.maxSteps = maxSteps;
@@ -154,6 +210,8 @@ export class LLMAgent {
154
210
  this.history = history;
155
211
  this.summarizer = summarizer;
156
212
  this.onTranscript = onTranscript;
213
+ this.writeRoots = writeRoots;
214
+ this.compactor = compactor;
157
215
  this.tools = new Map(tools.map(tool => [tool.name, tool]));
158
216
  if (this.tools.size !== tools.length) {
159
217
  // The model calls tools by name, so a second one with the same name could never run.
@@ -227,12 +285,206 @@ export class LLMAgent {
227
285
  this.logger?.warn(`The summary of the undone turns failed: ${why}`);
228
286
  }, classifyHostFailure, 'LLM agent logger failed');
229
287
  }
230
- 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, }) {
231
480
  // Sent as a copy, so what the run records is what the agent built, whatever the client does.
232
- const context = [...this.conversation.contextAfter(after), ...structuredClone(runMessages)];
481
+ const context = compactedContext([
482
+ ...this.conversation.contextAfter(after),
483
+ ...structuredClone(runMessages),
484
+ ]);
233
485
  let response;
234
486
  try {
235
- response = await this.llmClient.send({ context, tools }, signal);
487
+ response = await this.llmClient.send({ context, tools, compactAt }, signal);
236
488
  }
237
489
  catch (error) {
238
490
  const failure = classifyProviderFailure(error, 'The LLM call failed');
@@ -276,6 +528,7 @@ export class LLMAgent {
276
528
  if (this.autoApprove || risk !== 'destructive')
277
529
  return undefined;
278
530
  const command = 'command' in prepared ? prepared.command : undefined;
531
+ const worktree = 'worktree' in prepared ? prepared.worktree : undefined;
279
532
  const retry = 'Do not retry the same call; choose another way, or ask the user.';
280
533
  if (!approve) {
281
534
  return `The call to "${call.name}" was denied: it is destructive, and no one was asked to allow it. ${retry}`;
@@ -292,6 +545,7 @@ export class LLMAgent {
292
545
  risk,
293
546
  // A copy too: the approver must not be able to change what runs.
294
547
  ...(command ? { command: structuredClone(command) } : {}),
548
+ ...(worktree ? { worktree: structuredClone(worktree) } : {}),
295
549
  };
296
550
  decision = readDecision(await approve(request, signal));
297
551
  }
@@ -610,12 +864,20 @@ export class LLMAgent {
610
864
  * Runs inside the context of the run that reached it, or in one of its own that it ends with
611
865
  * the run: completed, failed or cancelled. Only a run that owns its context names the history
612
866
  * run it recorded, since the run is over only when that context ends.
867
+ *
868
+ * @throws {RunInProgressError} when another run on this agent is still working.
613
869
  */
614
- async run(prompt, options) {
870
+ run(prompt, options) {
871
+ return this.runs.run(() => this.runAlone(prompt, options));
872
+ }
873
+ async runAlone(prompt, options) {
874
+ const session = sessionOf(options) ?? this.session;
875
+ // Returned as copies: a consumer changing one must not change what later runs record.
615
876
  const inherited = runContextOf(options);
616
- if (inherited)
617
- return this.runSteps(prompt, options, inherited);
618
- const context = new RunContext();
877
+ if (inherited) {
878
+ return { ...(await this.runSteps(prompt, options, inherited)), session: { ...session } };
879
+ }
880
+ const context = new RunContext(session);
619
881
  let end = 'failed';
620
882
  let response;
621
883
  try {
@@ -632,7 +894,7 @@ export class LLMAgent {
632
894
  }
633
895
  // Read once the run ended: a run that changed nothing left the history then.
634
896
  const { runId } = context.historyRun;
635
- return runId ? { ...response, runId } : response;
897
+ return { ...response, ...(runId ? { runId } : {}), session: { ...session } };
636
898
  }
637
899
  /**
638
900
  * What the run started ends here, but its result stands: the changes it recorded are already
@@ -675,7 +937,7 @@ export class LLMAgent {
675
937
  description,
676
938
  inputSchema,
677
939
  })),
678
- messages: seen.messages(),
940
+ messages: seen.messages().map(withoutKeptCopies),
679
941
  ...(outcome === 'completed' ? {} : { error: describeFailure(error) }),
680
942
  });
681
943
  await this.onTranscript({
@@ -700,6 +962,7 @@ export class LLMAgent {
700
962
  }
701
963
  async takeSteps(prompt, options, context, seen) {
702
964
  const { signal, onProgress: callback = ignoreProgress } = options;
965
+ const delegation = delegationOf(options);
703
966
  // A tool narrates through this without ever failing: a failing callback would reach
704
967
  // only the tool, which turns any failure into a result for the model. The run ends on
705
968
  // it once that tool returns.
@@ -716,6 +979,21 @@ export class LLMAgent {
716
979
  };
717
980
  // The agents the run's calls send to work in the background.
718
981
  const background = new BackgroundAgents(signal, onProgress);
982
+ // Checked whatever it is, as a JavaScript consumer can pass anything.
983
+ const inbox = options.inbox === undefined ? undefined : readerOf(options.inbox);
984
+ // What the user sends while the run waits for its agents is worth the model's attention.
985
+ const stopListening = inbox?.onMessage(() => {
986
+ background.interrupt();
987
+ });
988
+ // What the run read from its inbox and has not kept yet, which goes back there if the run
989
+ // is not kept.
990
+ const unkept = [];
991
+ const takeSent = () => {
992
+ const sent = takeInbox(inbox);
993
+ if (sent)
994
+ unkept.push(...sent.texts);
995
+ return sent?.message;
996
+ };
719
997
  const start = Date.now();
720
998
  const runMessages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
721
999
  let tokens;
@@ -726,15 +1004,22 @@ export class LLMAgent {
726
1004
  // The turn this one follows: the last recorded, unless the workspace moved since.
727
1005
  let after = this.conversation.currentTurn;
728
1006
  let followed;
729
- // 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.
730
1012
  seen.messages = () => [...this.conversation.contextAfter(after), ...runMessages];
731
1013
  try {
732
1014
  if (this.history) {
733
1015
  followed = await this.followHistory(this.history);
734
1016
  after = followed.turn;
735
1017
  // The files may no longer hold what the conversation saw of them.
736
- if (followed.revisions.length > 0)
1018
+ if (followed.revisions.length > 0) {
737
1019
  this.reads.clear();
1020
+ // Its context is no longer the one last sent.
1021
+ contextTokens = undefined;
1022
+ }
738
1023
  if (followed.undone.length > 0) {
739
1024
  const summary = await this.summarizeUndone(followed, signal);
740
1025
  tokens = addTokens(tokens, summary.usage);
@@ -773,11 +1058,13 @@ export class LLMAgent {
773
1058
  // Recorded only once the run ends in a way that keeps it, so it never leaves a tool call
774
1059
  // without its result; a run that is not kept gives its note on what was undone again.
775
1060
  const remember = () => {
1061
+ unkept.length = 0;
776
1062
  this.conversation.addRunAfter(after, runMessages, context.historyRun);
777
1063
  // With the turns that read them, and only then.
778
1064
  reads.commit();
779
1065
  if (followed)
780
1066
  this.seenRevision = followed.latestRevision;
1067
+ this.contextTokens = contextTokens;
781
1068
  };
782
1069
  // A run kept although it failed stops its agents first, and tells the next run what
783
1070
  // became of each one, which would otherwise wait for answers that never come.
@@ -788,20 +1075,110 @@ export class LLMAgent {
788
1075
  runMessages.push(ended);
789
1076
  remember();
790
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;
791
1088
  for (let step = 1; step <= this.maxSteps; step++) {
792
1089
  // An agent at work may have narrated through a callback that failed.
793
1090
  throwIfProgressFailed();
794
- const { response, context: sent } = await this.llmCall(runMessages, after, {
795
- signal,
796
- step,
797
- callback,
798
- tools,
799
- });
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;
800
1155
  tokens = addTokens(tokens, response.usage);
801
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;
802
1168
  // After the tokens are counted, so a throwing callback still reports them, and for an
803
1169
  // unusable answer too: the call was sent, and billed.
804
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;
805
1182
  throwIfProgressFailed();
806
1183
  if (response.stopReason === 'truncated') {
807
1184
  // What it wrote before the output limit, kept with the run so the next run can
@@ -833,21 +1210,28 @@ export class LLMAgent {
833
1210
  const calls = response.message.content.filter(part => part.type === 'toolCall');
834
1211
  if (calls.length === 0) {
835
1212
  // An answer given while agents work, or before the model read what they answered,
836
- // is a wait: the model goes on with their answers.
1213
+ // is a wait: the model goes on with their answers. So is one given before it read
1214
+ // what the user sent, while a step is left to answer it; otherwise the message waits
1215
+ // in the inbox for the next run.
1216
+ const lastStep = step === this.maxSteps;
837
1217
  let answers = background.takeAnswers();
838
- if (!answers && background.working && step < this.maxSteps) {
1218
+ let sent = lastStep ? undefined : takeSent();
1219
+ if (!answers && !sent && background.working && !lastStep) {
839
1220
  await background.nextAnswer();
840
1221
  throwIfProgressFailed();
841
1222
  answers = background.takeAnswers();
1223
+ sent = takeSent();
842
1224
  }
843
- if (answers || background.working) {
844
- if (step === this.maxSteps) {
1225
+ if (answers || sent || background.working) {
1226
+ if (lastStep) {
845
1227
  throw new UnrecoverableError('The LLM did not finish within its step limit', {
846
1228
  cause: `The model was still waiting for the agents it sent after ${String(this.maxSteps)} steps.`,
847
1229
  });
848
1230
  }
849
1231
  if (answers)
850
1232
  runMessages.push(answers);
1233
+ if (sent)
1234
+ runMessages.push(sent);
851
1235
  continue;
852
1236
  }
853
1237
  remember();
@@ -901,7 +1285,9 @@ export class LLMAgent {
901
1285
  reads,
902
1286
  onProgress,
903
1287
  spend,
904
- depth: delegationOf(options)?.depth ?? 0,
1288
+ depth: delegation?.depth ?? 0,
1289
+ ...(delegation?.writeCeiling ? { writeCeiling: delegation.writeCeiling } : {}),
1290
+ ...(this.writeRoots ? { agentWriteRoots: this.writeRoots } : {}),
905
1291
  ...(options.approve ? { approve: options.approve } : {}),
906
1292
  ...(options.ask ? { ask: options.ask } : {}),
907
1293
  }, throwIfProgressFailed, loops, background);
@@ -911,10 +1297,14 @@ export class LLMAgent {
911
1297
  await keepFailed();
912
1298
  throw stopped;
913
1299
  }
914
- // What agents answered meanwhile, after the results the model is waiting for.
1300
+ // What agents answered and the user sent meanwhile, after the results the model is
1301
+ // waiting for.
915
1302
  const answers = background.takeAnswers();
916
1303
  if (answers)
917
1304
  runMessages.push(answers);
1305
+ const sent = takeSent();
1306
+ if (sent)
1307
+ runMessages.push(sent);
918
1308
  }
919
1309
  }
920
1310
  throw new UnrecoverableError('The LLM did not finish within its step limit', {
@@ -925,6 +1315,8 @@ export class LLMAgent {
925
1315
  // The run ends only after the agents still working, which stop with it: what they
926
1316
  // changed and spent is the run's.
927
1317
  await background.stop();
1318
+ // A run not kept read nothing: what it took belongs to the next attempt or run.
1319
+ inbox?.giveBack(unkept);
928
1320
  let failure = error;
929
1321
  if (effects.toolRan && error instanceof RecoverableError) {
930
1322
  // RetryingAgent would run the whole prompt again, and with it every tool this run
@@ -940,6 +1332,9 @@ export class LLMAgent {
940
1332
  // The run spent the tokens of every step before the one that failed.
941
1333
  throw withSpentTokens(failure, tokens, unreported || background.usageUnknown);
942
1334
  }
1335
+ finally {
1336
+ stopListening?.();
1337
+ }
943
1338
  }
944
1339
  }
945
1340
  //# sourceMappingURL=llmAgent.js.map