@mikode13/harness 10.0.0 → 11.0.1

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.
@@ -6,12 +6,13 @@ import { classifyHostFailure, classifyLocalFailure, classifyProviderFailure, des
6
6
  import { Conversation } from '#src/llm/domain/conversation';
7
7
  import { asNative, nativeOf } from '#src/llm/domain/tool';
8
8
  import { readAnswers } from '#src/agent/domain/question';
9
- import { prepareCall, RunEndingToolError, ToolExecutionError, } from '#src/tools/domain/preparedCall';
9
+ import { isDelegating, prepareCall, RunEndingToolError, ToolExecutionError, } from '#src/tools/domain/preparedCall';
10
10
  import { ReadRegistry } from '#src/tools/domain/readRegistry';
11
11
  import { InvalidToolInputError } from '#src/tools/domain/tool';
12
12
  import { isAbortError } from '#src/shared/domain/isAbortError';
13
13
  import { addTokens } from '#src/shared/domain/tokens';
14
14
  import { anotherAttempt, askAt, CommandLoops, extensionNote, loopQuestion, loopStop, loopWarning, repeatedAnswersAt, repeatNote, repeatQuestion, repeatStop, stop, warnAt, } from "./commandLoops.js";
15
+ import { BackgroundAgents, sentNote } from "./backgroundAgents.js";
15
16
  import { measureContext } from "./stepContext.js";
16
17
  import { askUserDefinition, askUserName, describeAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
17
18
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
@@ -270,9 +271,11 @@ export class LLMAgent {
270
271
  * place. Only a `destructive` call is asked about: git can undo a `mutating` one. A throwing
271
272
  * approver ends the run, as any host callback does.
272
273
  */
273
- async denial(call, { risk, command }, signal, approve) {
274
+ async denial(call, prepared, signal, approve) {
275
+ const { risk } = prepared;
274
276
  if (this.autoApprove || risk !== 'destructive')
275
277
  return undefined;
278
+ const command = 'command' in prepared ? prepared.command : undefined;
276
279
  const retry = 'Do not retry the same call; choose another way, or ask the user.';
277
280
  if (!approve) {
278
281
  return `The call to "${call.name}" was denied: it is destructive, and no one was asked to allow it. ${retry}`;
@@ -307,9 +310,10 @@ export class LLMAgent {
307
310
  /**
308
311
  * Always answers the call: a missing tool, a denied call or a failing one becomes an error
309
312
  * result, so the model can correct itself. Only a cancellation, a failing approver, or host
310
- * code that failed inside the call, such as an agent's approver, escapes.
313
+ * code that failed inside the call escapes. A call that hands tasks to agents answers once
314
+ * it sent them, and `background` watches them from then on.
311
315
  */
312
- async runTool(call, { signal, onProgress: callback = ignoreProgress, approve, ask }, effects, toolCall) {
316
+ async runTool(call, { signal, onProgress: callback = ignoreProgress, approve, ask }, effects, toolCall, background) {
313
317
  const result = { type: 'toolResult', callId: call.id, name: call.name };
314
318
  signal.throwIfAborted();
315
319
  if (call.name === askUserName && ask && !this.tools.has(askUserName)) {
@@ -347,7 +351,10 @@ export class LLMAgent {
347
351
  }
348
352
  let prepared;
349
353
  try {
350
- prepared = await prepareCall(tool, call.input, signal, { ...toolCall, callId: call.id });
354
+ const context = { ...toolCall, callId: call.id };
355
+ prepared = isDelegating(tool)
356
+ ? await tool.delegate(call.input, signal, context)
357
+ : await prepareCall(tool, call.input, signal, context);
351
358
  }
352
359
  catch (error) {
353
360
  signal.throwIfAborted();
@@ -385,6 +392,25 @@ export class LLMAgent {
385
392
  // Set before `execute`, so a tool that fails halfway still counts: it may have had effects.
386
393
  effects.toolRan = true;
387
394
  const unrecorded = prepared.unrecorded === true && prepared.risk !== 'safe';
395
+ if ('start' in prepared) {
396
+ // Before its agents start, so the person watching reads it before anything they narrate.
397
+ emit({
398
+ type: 'tool',
399
+ id: call.id,
400
+ name: call.name,
401
+ status: 'sent',
402
+ ...(summary === undefined ? {} : { summary }),
403
+ }, callback);
404
+ const agents = prepared.start(background.signal);
405
+ background.add(call.id, agents, unrecorded);
406
+ return {
407
+ result: { ...result, output: sentNote(agents), isError: false },
408
+ denied: false,
409
+ sent: true,
410
+ unrecorded,
411
+ ...(summary === undefined ? {} : { summary }),
412
+ };
413
+ }
388
414
  try {
389
415
  const output = await prepared.run(signal);
390
416
  const ending = prepared.ending?.();
@@ -451,35 +477,26 @@ export class LLMAgent {
451
477
  };
452
478
  }
453
479
  /**
454
- * One at a time and in order, so their effects and their narration never interleave. Only
455
- * calls of a `parallel` tool next to each other run at once, such as agents being delegated
456
- * to, each narrated as it goes.
480
+ * One at a time and in order, so their effects and their narration never interleave. Agents a
481
+ * call hands tasks to keep working in the background once it answered.
457
482
  */
458
483
  async runTools(calls, options, effects, toolCall,
459
484
  // Throws the failure of a progress callback a call narrated through.
460
- throwIfProgressFailed, loops) {
485
+ throwIfProgressFailed, loops, background) {
461
486
  const { signal } = options;
462
487
  const results = [];
463
- // The calls before this index already ran, with the ones they ran beside.
464
- let ranTo = 0;
465
488
  for (const [index, call] of calls.entries()) {
466
- if (index < ranTo)
467
- continue;
468
489
  // A call before this one may have ignored the cancellation; this one must not start,
469
490
  // nor be announced as running.
470
491
  signal.throwIfAborted();
471
- const together = this.parallelFrom(calls, index);
472
- if (together.length > 1) {
473
- results.push(...(await this.runTogether(together, options, effects, toolCall, loops)));
474
- throwIfProgressFailed();
475
- ranTo = index + together.length;
476
- continue;
477
- }
478
492
  // `runTool` announces the call only once it is allowed to run, so a consumer never
479
493
  // shows a call as running early, nor while the user is still being asked.
480
- const ran = await this.runTool(call, options, effects, toolCall);
494
+ const ran = await this.runTool(call, options, effects, toolCall, background);
481
495
  // A call that failed to narrate ends the run before the next one can start.
482
496
  throwIfProgressFailed();
497
+ // An agent at work since the last call may have changed what the history cannot copy.
498
+ if (background.mayHaveChangedUnseen())
499
+ loops.unrecordedCallRan();
483
500
  const settled = this.settle(call, ran, loops, options);
484
501
  let { result } = settled;
485
502
  const { alike, key, label } = settled;
@@ -508,53 +525,12 @@ export class LLMAgent {
508
525
  }
509
526
  return { message: { role: 'tool', content: results } };
510
527
  }
511
- /** The calls from `index` on whose tool is `parallel`, up to the first that is not. */
512
- parallelFrom(calls, index) {
513
- const together = [];
514
- for (const call of calls.slice(index)) {
515
- const tool = this.tools.get(call.name);
516
- if (!tool || !('parallel' in tool) || tool.parallel !== true)
517
- break;
518
- together.push(call);
519
- }
520
- return together;
521
- }
522
- /**
523
- * Runs the calls at once, their results in the order of the calls. The first failure that
524
- * ends the run stops the others, and the run ends once every one has.
525
- */
526
- async runTogether(calls, options, effects, toolCall, loops) {
527
- const stopping = new AbortController();
528
- const together = { ...options, signal: AbortSignal.any([options.signal, stopping.signal]) };
529
- const ended = {};
530
- const settled = await Promise.allSettled(calls.map(async (call) => {
531
- try {
532
- const ran = await this.runTool(call, together, effects, toolCall);
533
- return this.settle(call, ran, loops, options).result;
534
- }
535
- catch (error) {
536
- ended.failure ??= { error };
537
- stopping.abort();
538
- throw error;
539
- }
540
- }));
541
- // The others were stopped for it: what ended the run is the first failure, or the user.
542
- options.signal.throwIfAborted();
543
- if (ended.failure) {
544
- const { error } = ended.failure;
545
- // A call stopped on its way may have spent on the model, and a stop carries no tokens:
546
- // the run's total is unknown, not short.
547
- const stopped = settled.some(outcome => outcome.status === 'rejected' && outcome.reason !== error);
548
- throw stopped ? withSpentTokens(error, undefined, true) : error;
549
- }
550
- return settled.flatMap(outcome => (outcome.status === 'fulfilled' ? [outcome.value] : []));
551
- }
552
528
  /**
553
529
  * Narrates how a call ended and notes its command's ending, with the warning for the model
554
530
  * when that ending keeps coming back alike.
555
531
  */
556
532
  settle(call, ran, loops, { onProgress: callback = ignoreProgress, ask }) {
557
- const { denied, diff, summary, exitCode, ending } = ran;
533
+ const { denied, diff, summary, exitCode, ending, sent } = ran;
558
534
  let { result } = ran;
559
535
  if (ran.unrecorded)
560
536
  loops.unrecordedCallRan();
@@ -580,6 +556,9 @@ export class LLMAgent {
580
556
  ...(summary === undefined ? {} : { summary }),
581
557
  }, callback);
582
558
  }
559
+ else if (sent === true) {
560
+ // Narrated as it sent its agents, which go on after it until each one ends.
561
+ }
583
562
  // The diff of a change it made, for the person watching; only once it was made.
584
563
  else if (diff !== undefined) {
585
564
  emit({
@@ -721,6 +700,22 @@ export class LLMAgent {
721
700
  }
722
701
  async takeSteps(prompt, options, context, seen) {
723
702
  const { signal, onProgress: callback = ignoreProgress } = options;
703
+ // A tool narrates through this without ever failing: a failing callback would reach
704
+ // only the tool, which turns any failure into a result for the model. The run ends on
705
+ // it once that tool returns.
706
+ let progressFailure;
707
+ const onProgress = (event) => {
708
+ if (progressFailure)
709
+ return;
710
+ try {
711
+ emit(event, callback);
712
+ }
713
+ catch (error) {
714
+ progressFailure = { error };
715
+ }
716
+ };
717
+ // The agents the run's calls send to work in the background.
718
+ const background = new BackgroundAgents(signal, onProgress);
724
719
  const start = Date.now();
725
720
  const runMessages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
726
721
  let tokens;
@@ -764,23 +759,11 @@ export class LLMAgent {
764
759
  tokens = addTokens(tokens, spent);
765
760
  unreported ||= unreportedCall;
766
761
  };
767
- // A tool narrates through this without ever failing: a failing callback would reach
768
- // only the tool, which turns any failure into a result for the model. The run ends on
769
- // it once that tool returns.
770
- let progressFailure;
771
- const onProgress = (event) => {
772
- if (progressFailure)
773
- return;
774
- try {
775
- emit(event, callback);
776
- }
777
- catch (error) {
778
- progressFailure = { error };
779
- }
780
- };
762
+ // Also ends the run at its next boundary when an agent at work failed in a way that ends it.
781
763
  const throwIfProgressFailed = () => {
782
764
  if (progressFailure)
783
765
  throw progressFailure.error;
766
+ background.throwIfEnded();
784
767
  };
785
768
  // The agent's own `askUser` exists only for a run someone can answer, and never beside
786
769
  // a consumer's tool of that name, which the consumer gave first and which stays theirs.
@@ -796,7 +779,18 @@ export class LLMAgent {
796
779
  if (followed)
797
780
  this.seenRevision = followed.latestRevision;
798
781
  };
782
+ // A run kept although it failed stops its agents first, and tells the next run what
783
+ // became of each one, which would otherwise wait for answers that never come.
784
+ const keepFailed = async () => {
785
+ await background.stop();
786
+ const ended = background.takeAnswers();
787
+ if (ended)
788
+ runMessages.push(ended);
789
+ remember();
790
+ };
799
791
  for (let step = 1; step <= this.maxSteps; step++) {
792
+ // An agent at work may have narrated through a callback that failed.
793
+ throwIfProgressFailed();
800
794
  const { response, context: sent } = await this.llmCall(runMessages, after, {
801
795
  signal,
802
796
  step,
@@ -808,6 +802,7 @@ export class LLMAgent {
808
802
  // After the tokens are counted, so a throwing callback still reports them, and for an
809
803
  // unusable answer too: the call was sent, and billed.
810
804
  this.narrateStep(step, response.usage, { context: sent, tools }, callback);
805
+ throwIfProgressFailed();
811
806
  if (response.stopReason === 'truncated') {
812
807
  // What it wrote before the output limit, kept with the run so the next run can
813
808
  // take it up. Not its calls, whose input may be cut, nor what only the provider
@@ -817,7 +812,7 @@ export class LLMAgent {
817
812
  narrate(part, callback);
818
813
  if (written.length > 0)
819
814
  runMessages.push({ role: 'assistant', content: written });
820
- remember();
815
+ await keepFailed();
821
816
  throw new UnrecoverableError('The LLM stopped before completing its answer', {
822
817
  cause: 'The model stopped with "truncated". The run, and what the answer held before the limit, are kept in the conversation.',
823
818
  });
@@ -837,6 +832,24 @@ export class LLMAgent {
837
832
  }
838
833
  const calls = response.message.content.filter(part => part.type === 'toolCall');
839
834
  if (calls.length === 0) {
835
+ // 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.
837
+ let answers = background.takeAnswers();
838
+ if (!answers && background.working && step < this.maxSteps) {
839
+ await background.nextAnswer();
840
+ throwIfProgressFailed();
841
+ answers = background.takeAnswers();
842
+ }
843
+ if (answers || background.working) {
844
+ if (step === this.maxSteps) {
845
+ throw new UnrecoverableError('The LLM did not finish within its step limit', {
846
+ cause: `The model was still waiting for the agents it sent after ${String(this.maxSteps)} steps.`,
847
+ });
848
+ }
849
+ if (answers)
850
+ runMessages.push(answers);
851
+ continue;
852
+ }
840
853
  remember();
841
854
  return {
842
855
  // Only the final answer: text from earlier steps was narrated as it came.
@@ -844,7 +857,7 @@ export class LLMAgent {
844
857
  .filter(part => part.type === 'text')
845
858
  .map(part => part.text)
846
859
  .join('\n'),
847
- tokens: unreported ? undefined : tokens,
860
+ tokens: unreported || background.usageUnknown ? undefined : tokens,
848
861
  duration: (Date.now() - start) / 1000,
849
862
  };
850
863
  }
@@ -875,7 +888,7 @@ export class LLMAgent {
875
888
  ...rest,
876
889
  ],
877
890
  });
878
- remember();
891
+ await keepFailed();
879
892
  throw decision;
880
893
  }
881
894
  repeats.reset();
@@ -891,13 +904,17 @@ export class LLMAgent {
891
904
  depth: delegationOf(options)?.depth ?? 0,
892
905
  ...(options.approve ? { approve: options.approve } : {}),
893
906
  ...(options.ask ? { ask: options.ask } : {}),
894
- }, throwIfProgressFailed, loops);
907
+ }, throwIfProgressFailed, loops, background);
895
908
  runMessages.push(repeatedNote === undefined ? message : withNote(message, repeatedNote));
896
909
  // Kept like a run cut by the output limit: the user may take it up.
897
910
  if (stopped) {
898
- remember();
911
+ await keepFailed();
899
912
  throw stopped;
900
913
  }
914
+ // What agents answered meanwhile, after the results the model is waiting for.
915
+ const answers = background.takeAnswers();
916
+ if (answers)
917
+ runMessages.push(answers);
901
918
  }
902
919
  }
903
920
  throw new UnrecoverableError('The LLM did not finish within its step limit', {
@@ -905,6 +922,9 @@ export class LLMAgent {
905
922
  });
906
923
  }
907
924
  catch (error) {
925
+ // The run ends only after the agents still working, which stop with it: what they
926
+ // changed and spent is the run's.
927
+ await background.stop();
908
928
  let failure = error;
909
929
  if (effects.toolRan && error instanceof RecoverableError) {
910
930
  // RetryingAgent would run the whole prompt again, and with it every tool this run
@@ -918,7 +938,7 @@ export class LLMAgent {
918
938
  failure = unrecoverable;
919
939
  }
920
940
  // The run spent the tokens of every step before the one that failed.
921
- throw withSpentTokens(failure, tokens, unreported);
941
+ throw withSpentTokens(failure, tokens, unreported || background.usageUnknown);
922
942
  }
923
943
  }
924
944
  }