@mikode13/harness 9.1.0 → 9.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.
package/README.md CHANGED
@@ -523,10 +523,17 @@ or per role, with no upper bound:
523
523
  createLLMOrchestrator({ workspace, maxSteps: { executor: 120 } });
524
524
  ```
525
525
 
526
- A run also stops sooner when the model is stuck: when five answers in a row ask for exactly
527
- the same tool calls, or when one command, with the same input, ends the same way with exactly
528
- the same output five times in a row, whatever the model did between them. `createLLMAgent`
529
- takes one number, 65 by default.
526
+ `createLLMAgent` takes one number, 65 by default.
527
+
528
+ A run also watches for a model that is stuck. Two endings of one command, with the same input,
529
+ are alike when it printed the same, all of it, colors aside and the times a known test runner
530
+ prints, and either failed both times or passed with no file changed between them: a check that
531
+ passes after every change is never counted. On the third ending alike the model is warned. On the
532
+ fifth, the run asks you through `ask` whether to go on, and so it does, before they run, when
533
+ five answers in a row ask for exactly the same tool calls. **Another attempt** lets it go on
534
+ once, **Stop** ends it, and words of your own reach the model. A run stopped this way, or with no
535
+ `ask` to ask, fails with `UnrecoverableError`, and is kept in the conversation with its changes,
536
+ so your next prompt continues from it.
530
537
 
531
538
  No conversation is compacted unless you ask. `compactAt` is the number of input tokens at
532
539
  which the provider compacts it on its side, replacing the turns before that call with its own
@@ -0,0 +1,59 @@
1
+ import type { UserQuestion } from '#src/agent/domain/question';
2
+ import type { CommandEnding } from '#src/tools/domain/preparedCall';
3
+ export declare const warnAt = 3;
4
+ export declare const askAt = 5;
5
+ export declare const repeatedAnswersAt = 5;
6
+ export declare const anotherAttempt = "Another attempt";
7
+ export declare const stop = "Stop";
8
+ interface Seen {
9
+ ending: CommandEnding;
10
+ unrecorded: number;
11
+ }
12
+ /** One command call's endings alike, this one included: how many, and the first of them. */
13
+ export interface Alike {
14
+ times: number;
15
+ first: Seen;
16
+ last: Seen;
17
+ }
18
+ /**
19
+ * Every ending of each command call in one run, by tool and input, to tell when one keeps
20
+ * ending alike. A failure printed again is alike whatever changed in between: the changes did
21
+ * not reach what it checks. A pass printed again is alike only when nothing changed, since a
22
+ * passing check prints the same whatever it checked. No other call breaks the count, and an
23
+ * ending alike an earlier one counts even with others between, so A, B, A is still suspect.
24
+ */
25
+ export declare class CommandLoops {
26
+ private readonly endings;
27
+ private unrecorded;
28
+ private extended;
29
+ /** A call ran that may have changed files the history does not record. */
30
+ unrecordedCallRan(): void;
31
+ /** Records how the call `key` ended, and returns its endings alike. */
32
+ observe(key: string, ending: CommandEnding): Alike;
33
+ /** Whether the user already let the run go on, so the next fifth ending alike stops it. */
34
+ get wentOn(): boolean;
35
+ /**
36
+ * Whether the user may still let the run go on: once per run, for a command or for repeated
37
+ * answers alike. Granting it forgets the endings of the calls `keys` names, the ones the user
38
+ * let run again, so their counts start again and the same loop is not judged twice.
39
+ */
40
+ extend(keys: readonly string[]): boolean;
41
+ }
42
+ /**
43
+ * Told to the model with the result that makes the third ending alike. `asks` is whether the
44
+ * fifth will ask the user: not once they let the run go on, nor when no one can be asked.
45
+ */
46
+ export declare function loopWarning(label: string, alike: Alike, asks: boolean): string;
47
+ /** The question for the user on the fifth ending alike. */
48
+ export declare function loopQuestion(label: string, alike: Alike): UserQuestion;
49
+ /** Told to the model when the user let the run go on, with their words if they wrote some. */
50
+ export declare function extensionNote(alike: Alike, words?: string): string;
51
+ /** Why the run stopped, for the failure it ends with. */
52
+ export declare function loopStop(label: string, alike: Alike, reason: string): string;
53
+ /** The question for the user when the model asks for the same `calls` again, before they run. */
54
+ export declare function repeatQuestion(calls: string): UserQuestion;
55
+ /** Told to the model, with the results of the calls, when the user let them run again. */
56
+ export declare function repeatNote(words?: string): string;
57
+ /** Why the run stopped on repeated answers, for the failure it ends with. */
58
+ export declare function repeatStop(reason: string): string;
59
+ export {};
@@ -0,0 +1,143 @@
1
+ // The third ending alike warns the model; the fifth asks the user whether the run goes on.
2
+ export const warnAt = 3;
3
+ export const askAt = 5;
4
+ // So many answers in a row asking for exactly the same calls ask the user too, before they run.
5
+ export const repeatedAnswersAt = 5;
6
+ export const anotherAttempt = 'Another attempt';
7
+ export const stop = 'Stop';
8
+ // The end of a repeated output the user is shown: enough to recognize it.
9
+ const shownLines = 12;
10
+ const shownCharacters = 2_000;
11
+ /**
12
+ * Every ending of each command call in one run, by tool and input, to tell when one keeps
13
+ * ending alike. A failure printed again is alike whatever changed in between: the changes did
14
+ * not reach what it checks. A pass printed again is alike only when nothing changed, since a
15
+ * passing check prints the same whatever it checked. No other call breaks the count, and an
16
+ * ending alike an earlier one counts even with others between, so A, B, A is still suspect.
17
+ */
18
+ export class CommandLoops {
19
+ endings = new Map();
20
+ unrecorded = 0;
21
+ extended = false;
22
+ /** A call ran that may have changed files the history does not record. */
23
+ unrecordedCallRan() {
24
+ this.unrecorded++;
25
+ }
26
+ /** Records how the call `key` ended, and returns its endings alike. */
27
+ observe(key, ending) {
28
+ const seen = this.endings.get(key) ?? [];
29
+ const now = { ending, unrecorded: this.unrecorded };
30
+ const alike = seen.filter(earlier => sameEnding(earlier, now));
31
+ seen.push(now);
32
+ this.endings.set(key, seen);
33
+ return { times: alike.length + 1, first: alike[0] ?? now, last: now };
34
+ }
35
+ /** Whether the user already let the run go on, so the next fifth ending alike stops it. */
36
+ get wentOn() {
37
+ return this.extended;
38
+ }
39
+ /**
40
+ * Whether the user may still let the run go on: once per run, for a command or for repeated
41
+ * answers alike. Granting it forgets the endings of the calls `keys` names, the ones the user
42
+ * let run again, so their counts start again and the same loop is not judged twice.
43
+ */
44
+ extend(keys) {
45
+ if (this.extended)
46
+ return false;
47
+ this.extended = true;
48
+ for (const key of keys)
49
+ this.endings.delete(key);
50
+ return true;
51
+ }
52
+ }
53
+ function sameEnding(earlier, now) {
54
+ const a = earlier.ending;
55
+ const b = now.ending;
56
+ if (a.passed !== b.passed || a.how !== b.how || a.fingerprint !== b.fingerprint)
57
+ return false;
58
+ if (!b.passed)
59
+ return true;
60
+ // A state that could not be read, or a call the history cannot see, may hide a change.
61
+ return a.state !== undefined && a.state === b.state && earlier.unrecorded === now.unrecorded;
62
+ }
63
+ /** What changed between the first ending alike and the last, as the model and the user read it. */
64
+ function describeChanges({ first, last }) {
65
+ const files = [...new Set(last.ending.changed.slice(first.ending.changed.length))];
66
+ const unseen = last.unrecorded > first.unrecorded
67
+ ? ' Tools whose changes the history cannot see also ran.'
68
+ : '';
69
+ if (files.length === 0)
70
+ return `No file the history covers changed between them.${unseen}`;
71
+ const back = first.ending.state !== undefined && first.ending.state === last.ending.state
72
+ ? ', and went back to what they held'
73
+ : '';
74
+ return `Files changed between them${back}: ${files.join(', ')}.${unseen}`;
75
+ }
76
+ /**
77
+ * Told to the model with the result that makes the third ending alike. `asks` is whether the
78
+ * fifth will ask the user: not once they let the run go on, nor when no one can be asked.
79
+ */
80
+ export function loopWarning(label, alike, asks) {
81
+ const again = alike.last.ending.passed
82
+ ? 'Running it again with nothing changed tells you nothing new.'
83
+ : 'Find out why your changes do not reach what it reports before you run it again.';
84
+ const then = asks ? 'the user is asked whether the run goes on' : 'the run stops';
85
+ return `"${label}" ${alike.last.ending.how} with the same output ${String(alike.times)} times in this run. ${describeChanges(alike)} ${again} If it ends this way ${String(askAt)} times, ${then}.`;
86
+ }
87
+ /** The question for the user on the fifth ending alike. */
88
+ export function loopQuestion(label, alike) {
89
+ const lines = alike.last.ending.output.trimEnd().split('\n').slice(-shownLines).join('\n');
90
+ const output = lines.length > shownCharacters ? `…${lines.slice(-shownCharacters)}` : lines || '(nothing)';
91
+ return {
92
+ question: [
93
+ `Possible loop: "${label}" ${alike.last.ending.how} with the same output ${String(alike.times)} times in this run. ${describeChanges(alike)}`,
94
+ 'It may be a check that passes after every change, which is no loop, or the model repeating itself.',
95
+ `What it printed, the last lines:\n${output}`,
96
+ ].join('\n'),
97
+ options: loopOptions(`If it ends this way ${String(askAt)} more times`),
98
+ multiSelect: false,
99
+ };
100
+ }
101
+ function loopOptions(again) {
102
+ return [
103
+ {
104
+ label: anotherAttempt,
105
+ description: `Let the run go on, once. ${again}, or any other loop is found, the run stops without asking.`,
106
+ },
107
+ {
108
+ label: stop,
109
+ description: 'End the run here. What it did and changed is kept, and your next prompt continues from it.',
110
+ },
111
+ ];
112
+ }
113
+ /** Told to the model when the user let the run go on, with their words if they wrote some. */
114
+ export function extensionNote(alike, words) {
115
+ const said = words === undefined ? '' : ` They said: ${words}`;
116
+ return `It has ended this way ${String(alike.times)} times. The user let the run go on, once.${said} Change your approach: if it ends this way ${String(askAt)} more times, the run stops.`;
117
+ }
118
+ /** Why the run stopped, for the failure it ends with. */
119
+ export function loopStop(label, alike, reason) {
120
+ return `"${label}" ${alike.last.ending.how} with the same output ${String(alike.times)} times. ${describeChanges(alike)} ${reason} The run is kept in the conversation.`;
121
+ }
122
+ /** The question for the user when the model asks for the same `calls` again, before they run. */
123
+ export function repeatQuestion(calls) {
124
+ return {
125
+ question: [
126
+ `Possible loop: the model asked for the same tool calls ${String(repeatedAnswersAt)} times in a row, with nothing else between.`,
127
+ 'It may be waiting for something that changes by itself, which is no loop, or repeating itself.',
128
+ `The calls:\n${calls}`,
129
+ ].join('\n'),
130
+ options: loopOptions(`If it asks for the same calls ${String(repeatedAnswersAt)} more times in a row`),
131
+ multiSelect: false,
132
+ };
133
+ }
134
+ /** Told to the model, with the results of the calls, when the user let them run again. */
135
+ export function repeatNote(words) {
136
+ const said = words === undefined ? '' : ` They said: ${words}`;
137
+ return `You asked for these same calls ${String(repeatedAnswersAt)} times in a row. The user let the run go on, once.${said} Change your approach: if you ask for the same calls ${String(repeatedAnswersAt)} more times in a row, the run stops.`;
138
+ }
139
+ /** Why the run stopped on repeated answers, for the failure it ends with. */
140
+ export function repeatStop(reason) {
141
+ return `The model asked for the same tool calls ${String(repeatedAnswersAt)} times in a row. ${reason} The run is kept in the conversation.`;
142
+ }
143
+ //# sourceMappingURL=commandLoops.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commandLoops.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/commandLoops.ts"],"names":[],"mappings":"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","sourcesContent":["import type { UserQuestion } from '#src/agent/domain/question';\nimport type { CommandEnding } from '#src/tools/domain/preparedCall';\n\n// The third ending alike warns the model; the fifth asks the user whether the run goes on.\nexport const warnAt = 3;\nexport const askAt = 5;\n// So many answers in a row asking for exactly the same calls ask the user too, before they run.\nexport const repeatedAnswersAt = 5;\n\nexport const anotherAttempt = 'Another attempt';\nexport const stop = 'Stop';\n\n// The end of a repeated output the user is shown: enough to recognize it.\nconst shownLines = 12;\nconst shownCharacters = 2_000;\n\ninterface Seen {\n\tending: CommandEnding;\n\t// How many calls whose changes the history cannot see had run by then.\n\tunrecorded: number;\n}\n\n/** One command call's endings alike, this one included: how many, and the first of them. */\nexport interface Alike {\n\ttimes: number;\n\tfirst: Seen;\n\tlast: Seen;\n}\n\n/**\n * Every ending of each command call in one run, by tool and input, to tell when one keeps\n * ending alike. A failure printed again is alike whatever changed in between: the changes did\n * not reach what it checks. A pass printed again is alike only when nothing changed, since a\n * passing check prints the same whatever it checked. No other call breaks the count, and an\n * ending alike an earlier one counts even with others between, so A, B, A is still suspect.\n */\nexport class CommandLoops {\n\tprivate readonly endings = new Map<string, Seen[]>();\n\tprivate unrecorded = 0;\n\tprivate extended = false;\n\n\t/** A call ran that may have changed files the history does not record. */\n\tunrecordedCallRan(): void {\n\t\tthis.unrecorded++;\n\t}\n\n\t/** Records how the call `key` ended, and returns its endings alike. */\n\tobserve(key: string, ending: CommandEnding): Alike {\n\t\tconst seen = this.endings.get(key) ?? [];\n\t\tconst now = { ending, unrecorded: this.unrecorded };\n\t\tconst alike = seen.filter(earlier => sameEnding(earlier, now));\n\t\tseen.push(now);\n\t\tthis.endings.set(key, seen);\n\t\treturn { times: alike.length + 1, first: alike[0] ?? now, last: now };\n\t}\n\n\t/** Whether the user already let the run go on, so the next fifth ending alike stops it. */\n\tget wentOn(): boolean {\n\t\treturn this.extended;\n\t}\n\n\t/**\n\t * Whether the user may still let the run go on: once per run, for a command or for repeated\n\t * answers alike. Granting it forgets the endings of the calls `keys` names, the ones the user\n\t * let run again, so their counts start again and the same loop is not judged twice.\n\t */\n\textend(keys: readonly string[]): boolean {\n\t\tif (this.extended) return false;\n\t\tthis.extended = true;\n\t\tfor (const key of keys) this.endings.delete(key);\n\t\treturn true;\n\t}\n}\n\nfunction sameEnding(earlier: Seen, now: Seen): boolean {\n\tconst a = earlier.ending;\n\tconst b = now.ending;\n\tif (a.passed !== b.passed || a.how !== b.how || a.fingerprint !== b.fingerprint) return false;\n\tif (!b.passed) return true;\n\t// A state that could not be read, or a call the history cannot see, may hide a change.\n\treturn a.state !== undefined && a.state === b.state && earlier.unrecorded === now.unrecorded;\n}\n\n/** What changed between the first ending alike and the last, as the model and the user read it. */\nfunction describeChanges({ first, last }: Alike): string {\n\tconst files = [...new Set(last.ending.changed.slice(first.ending.changed.length))];\n\tconst unseen =\n\t\tlast.unrecorded > first.unrecorded\n\t\t\t? ' Tools whose changes the history cannot see also ran.'\n\t\t\t: '';\n\tif (files.length === 0) return `No file the history covers changed between them.${unseen}`;\n\tconst back =\n\t\tfirst.ending.state !== undefined && first.ending.state === last.ending.state\n\t\t\t? ', and went back to what they held'\n\t\t\t: '';\n\treturn `Files changed between them${back}: ${files.join(', ')}.${unseen}`;\n}\n\n/**\n * Told to the model with the result that makes the third ending alike. `asks` is whether the\n * fifth will ask the user: not once they let the run go on, nor when no one can be asked.\n */\nexport function loopWarning(label: string, alike: Alike, asks: boolean): string {\n\tconst again = alike.last.ending.passed\n\t\t? 'Running it again with nothing changed tells you nothing new.'\n\t\t: 'Find out why your changes do not reach what it reports before you run it again.';\n\tconst then = asks ? 'the user is asked whether the run goes on' : 'the run stops';\n\treturn `\"${label}\" ${alike.last.ending.how} with the same output ${String(alike.times)} times in this run. ${describeChanges(alike)} ${again} If it ends this way ${String(askAt)} times, ${then}.`;\n}\n\n/** The question for the user on the fifth ending alike. */\nexport function loopQuestion(label: string, alike: Alike): UserQuestion {\n\tconst lines = alike.last.ending.output.trimEnd().split('\\n').slice(-shownLines).join('\\n');\n\tconst output =\n\t\tlines.length > shownCharacters ? `…${lines.slice(-shownCharacters)}` : lines || '(nothing)';\n\treturn {\n\t\tquestion: [\n\t\t\t`Possible loop: \"${label}\" ${alike.last.ending.how} with the same output ${String(alike.times)} times in this run. ${describeChanges(alike)}`,\n\t\t\t'It may be a check that passes after every change, which is no loop, or the model repeating itself.',\n\t\t\t`What it printed, the last lines:\\n${output}`,\n\t\t].join('\\n'),\n\t\toptions: loopOptions(`If it ends this way ${String(askAt)} more times`),\n\t\tmultiSelect: false,\n\t};\n}\n\nfunction loopOptions(again: string): UserQuestion['options'] {\n\treturn [\n\t\t{\n\t\t\tlabel: anotherAttempt,\n\t\t\tdescription: `Let the run go on, once. ${again}, or any other loop is found, the run stops without asking.`,\n\t\t},\n\t\t{\n\t\t\tlabel: stop,\n\t\t\tdescription:\n\t\t\t\t'End the run here. What it did and changed is kept, and your next prompt continues from it.',\n\t\t},\n\t];\n}\n\n/** Told to the model when the user let the run go on, with their words if they wrote some. */\nexport function extensionNote(alike: Alike, words?: string): string {\n\tconst said = words === undefined ? '' : ` They said: ${words}`;\n\treturn `It has ended this way ${String(alike.times)} times. The user let the run go on, once.${said} Change your approach: if it ends this way ${String(askAt)} more times, the run stops.`;\n}\n\n/** Why the run stopped, for the failure it ends with. */\nexport function loopStop(label: string, alike: Alike, reason: string): string {\n\treturn `\"${label}\" ${alike.last.ending.how} with the same output ${String(alike.times)} times. ${describeChanges(alike)} ${reason} The run is kept in the conversation.`;\n}\n\n/** The question for the user when the model asks for the same `calls` again, before they run. */\nexport function repeatQuestion(calls: string): UserQuestion {\n\treturn {\n\t\tquestion: [\n\t\t\t`Possible loop: the model asked for the same tool calls ${String(repeatedAnswersAt)} times in a row, with nothing else between.`,\n\t\t\t'It may be waiting for something that changes by itself, which is no loop, or repeating itself.',\n\t\t\t`The calls:\\n${calls}`,\n\t\t].join('\\n'),\n\t\toptions: loopOptions(\n\t\t\t`If it asks for the same calls ${String(repeatedAnswersAt)} more times in a row`,\n\t\t),\n\t\tmultiSelect: false,\n\t};\n}\n\n/** Told to the model, with the results of the calls, when the user let them run again. */\nexport function repeatNote(words?: string): string {\n\tconst said = words === undefined ? '' : ` They said: ${words}`;\n\treturn `You asked for these same calls ${String(repeatedAnswersAt)} times in a row. The user let the run go on, once.${said} Change your approach: if you ask for the same calls ${String(repeatedAnswersAt)} more times in a row, the run stops.`;\n}\n\n/** Why the run stopped on repeated answers, for the failure it ends with. */\nexport function repeatStop(reason: string): string {\n\treturn `The model asked for the same tool calls ${String(repeatedAnswersAt)} times in a row. ${reason} The run is kept in the conversation.`;\n}\n"]}
@@ -78,6 +78,12 @@ export declare class LLMAgent implements Agent {
78
78
  private askUser;
79
79
  /** One at a time and in order, so their effects and their narration never interleave. */
80
80
  private runTools;
81
+ /**
82
+ * On a loop found, the fifth ending alike of a command or the fifth answer in a row asking for
83
+ * the same calls: the note for the model if the user lets the run go on, or the failure that
84
+ * stops it. The run goes on once at most, and never without someone to ask.
85
+ */
86
+ private decideOnLoop;
81
87
  /**
82
88
  * Runs inside the context of the run that reached it, or in one of its own that it ends with
83
89
  * the run: completed, failed or cancelled. Only a run that owns its context names the history
@@ -10,6 +10,7 @@ import { ReadRegistry } from '#src/tools/domain/readRegistry';
10
10
  import { InvalidToolInputError } from '#src/tools/domain/tool';
11
11
  import { isAbortError } from '#src/shared/domain/isAbortError';
12
12
  import { addTokens } from '#src/shared/domain/tokens';
13
+ import { anotherAttempt, askAt, CommandLoops, extensionNote, loopQuestion, loopStop, loopWarning, repeatedAnswersAt, repeatNote, repeatQuestion, repeatStop, stop, warnAt, } from "./commandLoops.js";
13
14
  import { measureContext } from "./stepContext.js";
14
15
  import { askUserDefinition, askUserName, describeAnswers, readAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
15
16
  import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
@@ -64,13 +65,17 @@ function canonical(value) {
64
65
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
65
66
  .map(([key, inner]) => [key, canonical(inner)]));
66
67
  }
67
- // A model that asks for exactly the same calls this many times in a row is stuck: it already
68
- // has what they return, and running them again changes nothing.
69
- const maxRepeatedSteps = 5;
70
- /** How many steps in a row have asked for exactly the same tool calls, with the same input. */
68
+ /**
69
+ * How many steps in a row have asked for exactly the same tool calls, with the same input. A
70
+ * model that keeps asking is likely stuck: it already has what they return.
71
+ */
71
72
  class RepeatedCalls {
72
73
  last;
73
74
  times = 0;
75
+ /** The user let the run go on: the count starts again. */
76
+ reset() {
77
+ this.times = 0;
78
+ }
74
79
  count(calls) {
75
80
  // Keys sorted at every level: the same input with its properties in another order is the
76
81
  // same call. Arrays, and the order of the calls, keep theirs.
@@ -80,27 +85,35 @@ class RepeatedCalls {
80
85
  return this.times;
81
86
  }
82
87
  }
83
- // A command that ends exactly as it did the last times, in output too, this many times in a row
84
- // means the model's changes in between are not reaching what the command checks.
85
- const maxRepeatedEndings = 5;
86
- /**
87
- * How many times in a row each command call, by tool and input, has ended exactly the same way.
88
- * Calls in between to other tools, or to other commands, do not break the count.
89
- */
90
- class RepeatedEndings {
91
- last = new Map();
92
- count(call, ending) {
93
- const key = JSON.stringify([call.name, canonical(call.input)]);
94
- // A call that never ran a command, such as one denied, says nothing of how it ends.
95
- if (ending === undefined) {
96
- this.last.delete(key);
97
- return 0;
98
- }
99
- const previous = this.last.get(key);
100
- const times = previous?.ending === ending ? previous.times + 1 : 1;
101
- this.last.set(key, { ending, times });
102
- return times;
103
- }
88
+ /** A call by its tool and input, its properties in any order. */
89
+ function callKey(call) {
90
+ return JSON.stringify([call.name, canonical(call.input)]);
91
+ }
92
+ // How a call the user is asked about is shown: its tool and input, cut short.
93
+ const shownCallCharacters = 300;
94
+ function describeCall(call) {
95
+ const text = `${call.name} ${JSON.stringify(canonical(call.input))}`;
96
+ return text.length > shownCallCharacters ? `${text.slice(0, shownCallCharacters)}…` : text;
97
+ }
98
+ /** The result of a call the run stopped before. */
99
+ function notRun(call) {
100
+ return {
101
+ type: 'toolResult',
102
+ callId: call.id,
103
+ name: call.name,
104
+ output: 'Not run: the run stopped before it.',
105
+ isError: true,
106
+ };
107
+ }
108
+ /** The tool results, with `note` for the model after the last of them. */
109
+ function withNote(message, note) {
110
+ const last = message.content.at(-1);
111
+ if (last?.type !== 'toolResult')
112
+ return message;
113
+ return {
114
+ ...message,
115
+ content: [...message.content.slice(0, -1), { ...last, output: `${last.output}\n\n${note}` }],
116
+ };
104
117
  }
105
118
  /**
106
119
  * An agent whose conversation is owned by MiKode: every run sends the whole context to a
@@ -370,12 +383,14 @@ export class LLMAgent {
370
383
  signal.throwIfAborted();
371
384
  // Set before `execute`, so a tool that fails halfway still counts: it may have had effects.
372
385
  effects.toolRan = true;
386
+ const unrecorded = prepared.unrecorded === true && prepared.risk !== 'safe';
373
387
  try {
374
388
  const output = await prepared.run(signal);
375
389
  const ending = prepared.ending?.();
376
390
  return {
377
391
  result: { ...result, output, isError: false },
378
392
  denied: false,
393
+ unrecorded,
379
394
  ...(summary === undefined ? {} : { summary }),
380
395
  ...(prepared.diff === undefined ? {} : { diff: prepared.diff }),
381
396
  ...(ending === undefined ? {} : { ending }),
@@ -387,6 +402,7 @@ export class LLMAgent {
387
402
  return {
388
403
  result: { ...result, output: describeFailure(error), isError: true },
389
404
  denied: false,
405
+ unrecorded,
390
406
  ...(summary === undefined ? {} : { summary }),
391
407
  ...(error instanceof ToolExecutionError && error.exitCode !== undefined
392
408
  ? { exitCode: error.exitCode }
@@ -434,18 +450,35 @@ export class LLMAgent {
434
450
  /** One at a time and in order, so their effects and their narration never interleave. */
435
451
  async runTools(calls, options, effects, toolCall,
436
452
  // Throws the failure of a progress callback a call narrated through.
437
- throwIfProgressFailed, endings) {
453
+ throwIfProgressFailed, loops) {
438
454
  const { signal, onProgress: callback = ignoreProgress } = options;
439
455
  const results = [];
440
- for (const call of calls) {
456
+ for (const [index, call] of calls.entries()) {
441
457
  // A call before this one may have ignored the cancellation; this one must not start,
442
458
  // nor be announced as running.
443
459
  signal.throwIfAborted();
444
460
  // `runTool` announces the call only once it is allowed to run, so a consumer never
445
461
  // shows a call as running early, nor while the user is still being asked.
446
- const { result, denied, diff, summary, exitCode, ending } = await this.runTool(call, options, effects, toolCall);
462
+ const ran = await this.runTool(call, options, effects, toolCall);
463
+ const { denied, diff, summary, exitCode, ending } = ran;
464
+ let { result } = ran;
447
465
  // A call that failed to narrate ends the run before the next one can start.
448
466
  throwIfProgressFailed();
467
+ if (ran.unrecorded)
468
+ loops.unrecordedCallRan();
469
+ const key = callKey(call);
470
+ const label = summary ?? call.name;
471
+ const alike = ending === undefined ? undefined : loops.observe(key, ending);
472
+ // A command that changed what the history cannot copy hides that change from the next.
473
+ if (ending !== undefined && ending.state === undefined)
474
+ loops.unrecordedCallRan();
475
+ // Narrated with it, so the person watching reads what the model is told.
476
+ if (alike?.times === warnAt) {
477
+ result = {
478
+ ...result,
479
+ output: `${result.output}\n\n${loopWarning(label, alike, options.ask !== undefined && !loops.wentOn)}`,
480
+ };
481
+ }
449
482
  if (denied) {
450
483
  emit({
451
484
  type: 'tool',
@@ -468,15 +501,62 @@ export class LLMAgent {
468
501
  }
469
502
  else
470
503
  narrate(result, callback, summary, exitCode);
471
- results.push(result);
472
- // After its result is narrated, so the consumer sees the ending that stopped the run.
473
- if (endings.count(call, ending) >= maxRepeatedEndings) {
474
- throw new UnrecoverableError('The LLM kept getting the same result from a command', {
475
- cause: `${summary === undefined ? `"${call.name}"` : `"${summary}"`} ended the same way, with the same output, ${String(maxRepeatedEndings)} times in a row.`,
476
- });
504
+ // After its result is narrated, so the user is asked about the ending they have seen.
505
+ if (alike !== undefined && alike.times >= askAt) {
506
+ const decision = await this.decideOnLoop({
507
+ question: loopQuestion(label, alike),
508
+ failure: 'The LLM kept getting the same result from a command',
509
+ stopped: reason => loopStop(label, alike, reason),
510
+ note: words => extensionNote(alike, words),
511
+ }, () => loops.extend([key]), options);
512
+ if (typeof decision !== 'string') {
513
+ // So the next run's model reads why this one ended where it did.
514
+ results.push({
515
+ ...result,
516
+ output: `${result.output}\n\nThe run stopped here: ${decision.cause}`,
517
+ });
518
+ // The calls after it never run, but each still gets a result, so the run that is
519
+ // kept has none without one.
520
+ results.push(...calls.slice(index + 1).map(notRun));
521
+ return { message: { role: 'tool', content: results }, stopped: decision };
522
+ }
523
+ result = { ...result, output: `${result.output}\n\n${decision}` };
477
524
  }
525
+ results.push(result);
478
526
  }
479
- return { role: 'tool', content: results };
527
+ return { message: { role: 'tool', content: results } };
528
+ }
529
+ /**
530
+ * On a loop found, the fifth ending alike of a command or the fifth answer in a row asking for
531
+ * the same calls: the note for the model if the user lets the run go on, or the failure that
532
+ * stops it. The run goes on once at most, and never without someone to ask.
533
+ */
534
+ async decideOnLoop(loop, extend, { ask, signal }) {
535
+ const stopped = (reason) => new UnrecoverableError(loop.failure, { cause: loop.stopped(reason) });
536
+ if (!ask)
537
+ return stopped('No one could be asked whether to go on.');
538
+ if (!extend())
539
+ return stopped('The user had already let the run go on once.');
540
+ const questions = [loop.question];
541
+ let answers;
542
+ try {
543
+ answers = await ask(questions, signal);
544
+ }
545
+ catch (error) {
546
+ signal.throwIfAborted();
547
+ throw classifyHostFailure(error, 'The asker failed');
548
+ }
549
+ // An answer that arrives after a cancellation is not acted on.
550
+ signal.throwIfAborted();
551
+ const [answer] = readAnswers(answers, questions) ?? [];
552
+ if (answer === undefined) {
553
+ throw new UnrecoverableError('The asker failed', {
554
+ cause: 'It must answer the question with some text.',
555
+ });
556
+ }
557
+ if (answer === stop)
558
+ return stopped('The user stopped it.');
559
+ return loop.note(answer === anotherAttempt ? undefined : answer);
480
560
  }
481
561
  /**
482
562
  * Runs inside the context of the run that reached it, or in one of its own that it ends with
@@ -596,7 +676,7 @@ export class LLMAgent {
596
676
  // After following the history, which may have made every earlier read stale.
597
677
  const reads = this.reads.begin();
598
678
  const repeats = new RepeatedCalls();
599
- const endings = new RepeatedEndings();
679
+ const loops = new CommandLoops();
600
680
  // A tool that does its own work on the model, such as running subagents, counts it here.
601
681
  const spend = (spent, unreportedCall) => {
602
682
  tokens = addTokens(tokens, spent);
@@ -687,15 +767,47 @@ export class LLMAgent {
687
767
  };
688
768
  }
689
769
  // Before running them again: the model has already seen what they return.
690
- if (repeats.count(calls) >= maxRepeatedSteps) {
691
- throw new UnrecoverableError('The LLM repeated the same tool calls', {
692
- cause: `The model asked for the same tool calls ${String(maxRepeatedSteps)} times in a row.`,
693
- });
770
+ let repeatedNote;
771
+ // Not on the last step, whose calls never run: there is nothing to let go on.
772
+ if (step < this.maxSteps && repeats.count(calls) >= repeatedAnswersAt) {
773
+ const decision = await this.decideOnLoop({
774
+ question: repeatQuestion(calls.map(describeCall).join('\n')),
775
+ failure: 'The LLM repeated the same tool calls',
776
+ stopped: repeatStop,
777
+ note: repeatNote,
778
+ }, () => loops.extend(calls.map(callKey)), options);
779
+ if (typeof decision !== 'string') {
780
+ // Kept like a run stopped on a command, every call given a result.
781
+ const [first, ...rest] = calls.map(notRun);
782
+ runMessages.push({
783
+ role: 'tool',
784
+ content: [
785
+ ...(first
786
+ ? [
787
+ {
788
+ ...first,
789
+ output: `Not run: the run stopped here. ${decision.cause}`,
790
+ },
791
+ ]
792
+ : []),
793
+ ...rest,
794
+ ],
795
+ });
796
+ remember();
797
+ throw decision;
798
+ }
799
+ repeats.reset();
800
+ repeatedNote = decision;
694
801
  }
695
802
  // On the last step no call is left to send the results to, so the tools do not run.
696
803
  if (step < this.maxSteps) {
697
- const results = await this.runTools(calls, options, effects, { run: context, reads, onProgress, spend }, throwIfProgressFailed, endings);
698
- runMessages.push(results);
804
+ const { message, stopped } = await this.runTools(calls, options, effects, { run: context, reads, onProgress, spend }, throwIfProgressFailed, loops);
805
+ runMessages.push(repeatedNote === undefined ? message : withNote(message, repeatedNote));
806
+ // Kept like a run cut by the output limit: the user may take it up.
807
+ if (stopped) {
808
+ remember();
809
+ throw stopped;
810
+ }
699
811
  }
700
812
  }
701
813
  throw new UnrecoverableError('The LLM did not finish within its step limit', {