@mikode13/harness 9.0.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 +11 -4
- package/dist/engines/domain/model/commandLoops.d.ts +59 -0
- package/dist/engines/domain/model/commandLoops.js +143 -0
- package/dist/engines/domain/model/commandLoops.js.map +1 -0
- package/dist/engines/domain/model/llmAgent.d.ts +6 -0
- package/dist/engines/domain/model/llmAgent.js +180 -50
- package/dist/engines/domain/model/llmAgent.js.map +1 -1
- package/dist/llm/infrastructure/claudeLLMClient.js +3 -3
- package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
- package/dist/recovery/domain/recoveryStore.d.ts +8 -0
- package/dist/recovery/domain/recoveryStore.js.map +1 -1
- package/dist/recovery/infrastructure/fileRecoveryStore.js +38 -2
- package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
- package/dist/tools/domain/preparedCall.d.ts +26 -4
- package/dist/tools/domain/preparedCall.js +1 -1
- package/dist/tools/domain/preparedCall.js.map +1 -1
- package/dist/tools/infrastructure/commandOutput.d.ts +14 -0
- package/dist/tools/infrastructure/commandOutput.js +99 -0
- package/dist/tools/infrastructure/commandOutput.js.map +1 -0
- package/dist/tools/infrastructure/commandProcess.d.ts +3 -1
- package/dist/tools/infrastructure/commandProcess.js +3 -1
- package/dist/tools/infrastructure/commandProcess.js.map +1 -1
- package/dist/tools/infrastructure/commandTool.js +25 -11
- package/dist/tools/infrastructure/commandTool.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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);
|
|
526
|
+
}
|
|
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);
|
|
478
544
|
}
|
|
479
|
-
|
|
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
|
|
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);
|
|
@@ -625,6 +705,15 @@ export class LLMAgent {
|
|
|
625
705
|
const offersAsk = options.ask !== undefined && !this.tools.has(askUserName);
|
|
626
706
|
const tools = offersAsk ? [...this.toolDefinitions, askUserDefinition] : this.toolDefinitions;
|
|
627
707
|
seen.tools = tools;
|
|
708
|
+
// Recorded only once the run ends in a way that keeps it, so it never leaves a tool call
|
|
709
|
+
// without its result; a run that is not kept gives its note on what was undone again.
|
|
710
|
+
const remember = () => {
|
|
711
|
+
this.conversation.addRunAfter(after, runMessages, context.historyRun);
|
|
712
|
+
// With the turns that read them, and only then.
|
|
713
|
+
reads.commit();
|
|
714
|
+
if (followed)
|
|
715
|
+
this.seenRevision = followed.latestRevision;
|
|
716
|
+
};
|
|
628
717
|
for (let step = 1; step <= this.maxSteps; step++) {
|
|
629
718
|
const { response, context: sent } = await this.llmCall(runMessages, after, {
|
|
630
719
|
signal,
|
|
@@ -637,8 +726,23 @@ export class LLMAgent {
|
|
|
637
726
|
// After the tokens are counted, so a throwing callback still reports them, and for an
|
|
638
727
|
// unusable answer too: the call was sent, and billed.
|
|
639
728
|
this.narrateStep(step, response.usage, { context: sent, tools }, callback);
|
|
729
|
+
if (response.stopReason === 'truncated') {
|
|
730
|
+
// What it wrote before the output limit, kept with the run so the next run can
|
|
731
|
+
// take it up. Not its calls, whose input may be cut, nor what only the provider
|
|
732
|
+
// reads back, which it may refuse incomplete.
|
|
733
|
+
const written = response.message.content.filter(part => part.type === 'text' || part.type === 'reasoning');
|
|
734
|
+
for (const part of written)
|
|
735
|
+
narrate(part, callback);
|
|
736
|
+
if (written.length > 0)
|
|
737
|
+
runMessages.push({ role: 'assistant', content: written });
|
|
738
|
+
remember();
|
|
739
|
+
throw new UnrecoverableError('The LLM stopped before completing its answer', {
|
|
740
|
+
cause: 'The model stopped with "truncated". The run, and what the answer held before the limit, are kept in the conversation.',
|
|
741
|
+
});
|
|
742
|
+
}
|
|
640
743
|
if (response.stopReason !== 'completed') {
|
|
641
|
-
// Its tokens are already counted with the run's, which the failure carries.
|
|
744
|
+
// Its tokens are already counted with the run's, which the failure carries. A run
|
|
745
|
+
// that ends so is not remembered.
|
|
642
746
|
throw new UnrecoverableError('The LLM stopped before completing its answer', {
|
|
643
747
|
cause: `The model stopped with "${response.stopReason}".`,
|
|
644
748
|
});
|
|
@@ -651,13 +755,7 @@ export class LLMAgent {
|
|
|
651
755
|
}
|
|
652
756
|
const calls = response.message.content.filter(part => part.type === 'toolCall');
|
|
653
757
|
if (calls.length === 0) {
|
|
654
|
-
|
|
655
|
-
// note about what was undone is given again to the next run.
|
|
656
|
-
this.conversation.addRunAfter(after, runMessages, context.historyRun);
|
|
657
|
-
// With the turns that read them, and only then: a failed run is not remembered.
|
|
658
|
-
reads.commit();
|
|
659
|
-
if (followed)
|
|
660
|
-
this.seenRevision = followed.latestRevision;
|
|
758
|
+
remember();
|
|
661
759
|
return {
|
|
662
760
|
// Only the final answer: text from earlier steps was narrated as it came.
|
|
663
761
|
response: response.message.content
|
|
@@ -669,15 +767,47 @@ export class LLMAgent {
|
|
|
669
767
|
};
|
|
670
768
|
}
|
|
671
769
|
// Before running them again: the model has already seen what they return.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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;
|
|
676
801
|
}
|
|
677
802
|
// On the last step no call is left to send the results to, so the tools do not run.
|
|
678
803
|
if (step < this.maxSteps) {
|
|
679
|
-
const
|
|
680
|
-
runMessages.push(
|
|
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
|
+
}
|
|
681
811
|
}
|
|
682
812
|
}
|
|
683
813
|
throw new UnrecoverableError('The LLM did not finish within its step limit', {
|