@mikode13/harness 11.1.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -10
- package/dist/agent/domain/agent.d.ts +17 -0
- package/dist/agent/domain/agent.js.map +1 -1
- package/dist/agent/domain/runContext.d.ts +5 -0
- package/dist/agent/domain/runContext.js.map +1 -1
- package/dist/engines/domain/model/compactor.d.ts +40 -0
- package/dist/engines/domain/model/compactor.js +5 -0
- package/dist/engines/domain/model/compactor.js.map +1 -0
- package/dist/engines/domain/model/llmAgent.d.ts +48 -2
- package/dist/engines/domain/model/llmAgent.js +351 -14
- package/dist/engines/domain/model/llmAgent.js.map +1 -1
- package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
- package/dist/engines/domain/model/recentCompaction.js +119 -0
- package/dist/engines/domain/model/recentCompaction.js.map +1 -0
- package/dist/engines/domain/model/stepContext.js +3 -0
- package/dist/engines/domain/model/stepContext.js.map +1 -1
- package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
- package/dist/engines/domain/model/triagedCompaction.js +60 -0
- package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
- package/dist/factory/infrastructure/agentLLMFactory.d.ts +40 -12
- package/dist/factory/infrastructure/agentLLMFactory.js +114 -18
- package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/domain/contextTriage.d.ts +26 -0
- package/dist/llm/domain/contextTriage.js +3 -0
- package/dist/llm/domain/contextTriage.js.map +1 -0
- package/dist/llm/domain/llm.d.ts +18 -1
- package/dist/llm/domain/llm.js.map +1 -1
- package/dist/llm/domain/message.d.ts +18 -1
- package/dist/llm/domain/message.js +20 -0
- package/dist/llm/domain/message.js.map +1 -1
- package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
- package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
- package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
- package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
- package/dist/llm/infrastructure/jevContextTriage.js +92 -0
- package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
- package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
- package/dist/llm/infrastructure/openAILLMClient.js +20 -9
- package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
- package/dist/tools/domain/preparedCall.d.ts +2 -1
- package/dist/tools/domain/preparedCall.js +7 -1
- package/dist/tools/domain/preparedCall.js.map +1 -1
- package/dist/tools/domain/readRegistry.d.ts +11 -0
- package/dist/tools/domain/readRegistry.js.map +1 -1
- package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
- package/dist/tools/infrastructure/agentAsTool.js +9 -0
- package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
- package/dist/tools/infrastructure/commandTool.js +4 -1
- package/dist/tools/infrastructure/commandTool.js.map +1 -1
- package/dist/tools/infrastructure/exploreTool.js +2 -0
- package/dist/tools/infrastructure/exploreTool.js.map +1 -1
- package/dist/tools/infrastructure/fileTools.js +1 -0
- package/dist/tools/infrastructure/fileTools.js.map +1 -1
- package/dist/tools/infrastructure/removeWorktreeTool.js +7 -1
- package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -1
- package/dist/tools/infrastructure/subagents.d.ts +3 -1
- package/dist/tools/infrastructure/subagents.js +2 -1
- package/dist/tools/infrastructure/subagents.js.map +1 -1
- package/dist/tools/infrastructure/trackedEdits.js +1 -0
- package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
- package/dist/tools/infrastructure/workspaceWrites.d.ts +13 -1
- package/dist/tools/infrastructure/workspaceWrites.js +18 -1
- package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
- package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
- package/dist/tools/infrastructure/writeCeiling.js +49 -0
- package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the recent compactor cuts `context`, the context the model was last sent with what the
|
|
3
|
+
* run added since: everything before the cut is summarized, and the run's prompt and its last
|
|
4
|
+
* answers, each with its tool results, are kept word for word after the summary. Answers of
|
|
5
|
+
* earlier runs are always summarized, so what is kept starts at the prompt and keeps its order.
|
|
6
|
+
* `undefined` when there is no answer to summarize.
|
|
7
|
+
*
|
|
8
|
+
* @param prompt the run's prompt. Found by identity; when it is not there, an earlier compaction
|
|
9
|
+
* of this run kept it, first, so it is in the summary's message, which is summarized again.
|
|
10
|
+
*/
|
|
11
|
+
export function recentCut(context, prompt, keptSteps) {
|
|
12
|
+
const found = context.indexOf(prompt);
|
|
13
|
+
const promptAt = Math.max(found, 0);
|
|
14
|
+
const answers = context.flatMap((message, index) => message.role === 'assistant' && index > promptAt ? [index] : []);
|
|
15
|
+
const earlier = context.slice(0, promptAt).some(message => message.role === 'assistant');
|
|
16
|
+
// At least one answer is summarized, or compacting would only add a summary.
|
|
17
|
+
const kept = Math.max(0, Math.min(keptSteps, earlier ? answers.length : answers.length - 1));
|
|
18
|
+
// A step starts at an answer, so each call keeps its results, which follow it.
|
|
19
|
+
const cut = kept === 0 ? context.length : (answers[answers.length - kept] ?? context.length);
|
|
20
|
+
const summarized = found === -1
|
|
21
|
+
? context.slice(0, cut)
|
|
22
|
+
: [...context.slice(0, found), ...context.slice(found + 1, cut)];
|
|
23
|
+
if (!summarized.some(message => message.role === 'assistant'))
|
|
24
|
+
return undefined;
|
|
25
|
+
return {
|
|
26
|
+
summarized,
|
|
27
|
+
kept: withoutReplayedReasoning([prompt, ...context.slice(cut)]),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Copies of `messages` without what only a provider reads back, which a changed context before
|
|
32
|
+
* it may no longer accept: its signed reasoning and its compactions. The last answer keeps its
|
|
33
|
+
* reasoning, since the provider needs it to go on with that answer's tool calls.
|
|
34
|
+
*/
|
|
35
|
+
export function withoutReplayedReasoning(messages) {
|
|
36
|
+
const last = messages.findLastIndex(message => message.role === 'assistant');
|
|
37
|
+
return messages.flatMap((message, index) => {
|
|
38
|
+
const content = structuredClone(message.content).filter(part => part.type !== 'compaction' &&
|
|
39
|
+
part.type !== 'contextSummary' &&
|
|
40
|
+
(part.type !== 'providerData' || index === last));
|
|
41
|
+
return content.length > 0 ? [{ role: message.role, content }] : [];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** What the recent compactor keeps, as its summary requests tell the model. */
|
|
45
|
+
export function recentlyKept(keptSteps) {
|
|
46
|
+
return `the user's request for this run and your last ${String(keptSteps)} answers, with their tool results,`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What the model is asked once its context passed the threshold, after that whole context.
|
|
50
|
+
*
|
|
51
|
+
* @param kept what is kept word for word after the summary, as `recentlyKept` says it.
|
|
52
|
+
*/
|
|
53
|
+
export function summaryRequest(kept) {
|
|
54
|
+
return {
|
|
55
|
+
role: 'user',
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: 'text',
|
|
59
|
+
text: `Your context is about to be compacted. Write a summary of this conversation so far that lets you go on without it: what the user asked, what you found and decided, what you changed and why, what is left to do, and what you would need to read again. After your summary, only ${kept} are kept word for word, and not your reasoning in them: say what you were thinking and planning there too. Answer with the summary alone, in plain text, and call no tool.`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// A context that no longer fits cannot be sent again to ask for its summary, so the model reads
|
|
65
|
+
// it as text, bounded to fit any model the harness offers.
|
|
66
|
+
const transcriptCharacters = 300_000;
|
|
67
|
+
const partCharacters = 3_000;
|
|
68
|
+
/**
|
|
69
|
+
* What the model is asked when the context no longer fit: the part to summarize, as text, since
|
|
70
|
+
* it can no longer be sent as it was.
|
|
71
|
+
*/
|
|
72
|
+
export function transcriptSummaryRequest(summarized, kept) {
|
|
73
|
+
return {
|
|
74
|
+
role: 'user',
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: 'text',
|
|
78
|
+
text: [
|
|
79
|
+
`Below is the start of a conversation in which you, an agent, work on a task for the user. It no longer fits your context, so write a summary of it that lets you go on without it: what the user asked, what you found and decided, what you changed and why, what is left to do, and what you would need to read again. Only ${kept} are kept word for word after your summary. Answer with the summary alone, in plain text.`,
|
|
80
|
+
'',
|
|
81
|
+
messagesAsText(summarized, transcriptCharacters, partCharacters),
|
|
82
|
+
].join('\n'),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `messages` as plain text, for a model to summarize: who said or did what, each part cut at
|
|
89
|
+
* `partChars`, and the whole at `maxChars`, marked when cut.
|
|
90
|
+
*/
|
|
91
|
+
export function messagesAsText(messages, maxChars, partChars) {
|
|
92
|
+
const lines = messages.flatMap(message => message.content.flatMap(part => partAsText(message, part, partChars)));
|
|
93
|
+
const text = lines.join('\n');
|
|
94
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n[the rest was cut]`;
|
|
95
|
+
}
|
|
96
|
+
function partAsText(message, part, partChars) {
|
|
97
|
+
switch (part.type) {
|
|
98
|
+
case 'text':
|
|
99
|
+
return [`${message.role}: ${shorten(part.text, partChars)}`];
|
|
100
|
+
case 'toolCall':
|
|
101
|
+
return [
|
|
102
|
+
`${message.role} called ${part.name} ${shorten(JSON.stringify(part.input), partChars)}`,
|
|
103
|
+
];
|
|
104
|
+
case 'toolResult':
|
|
105
|
+
return [
|
|
106
|
+
`${part.name} ${part.isError ? 'failed' : 'returned'}: ${shorten(part.output, partChars)}`,
|
|
107
|
+
];
|
|
108
|
+
// Readable reasoning, signed blocks and summaries only a provider reads are not the work.
|
|
109
|
+
case 'reasoning':
|
|
110
|
+
case 'providerData':
|
|
111
|
+
case 'compaction':
|
|
112
|
+
case 'contextSummary':
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function shorten(text, max) {
|
|
117
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=recentCompaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recentCompaction.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/recentCompaction.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Message, MessagePart } from '#src/llm/domain/message';\n\n/**\n * Where the recent compactor cuts `context`, the context the model was last sent with what the\n * run added since: everything before the cut is summarized, and the run's prompt and its last\n * answers, each with its tool results, are kept word for word after the summary. Answers of\n * earlier runs are always summarized, so what is kept starts at the prompt and keeps its order.\n * `undefined` when there is no answer to summarize.\n *\n * @param prompt the run's prompt. Found by identity; when it is not there, an earlier compaction\n * of this run kept it, first, so it is in the summary's message, which is summarized again.\n */\nexport function recentCut(\n\tcontext: Message[],\n\tprompt: Message,\n\tkeptSteps: number,\n): { summarized: Message[]; kept: Message[] } | undefined {\n\tconst found = context.indexOf(prompt);\n\tconst promptAt = Math.max(found, 0);\n\tconst answers = context.flatMap((message, index) =>\n\t\tmessage.role === 'assistant' && index > promptAt ? [index] : [],\n\t);\n\tconst earlier = context.slice(0, promptAt).some(message => message.role === 'assistant');\n\t// At least one answer is summarized, or compacting would only add a summary.\n\tconst kept = Math.max(0, Math.min(keptSteps, earlier ? answers.length : answers.length - 1));\n\t// A step starts at an answer, so each call keeps its results, which follow it.\n\tconst cut = kept === 0 ? context.length : (answers[answers.length - kept] ?? context.length);\n\tconst summarized =\n\t\tfound === -1\n\t\t\t? context.slice(0, cut)\n\t\t\t: [...context.slice(0, found), ...context.slice(found + 1, cut)];\n\tif (!summarized.some(message => message.role === 'assistant')) return undefined;\n\treturn {\n\t\tsummarized,\n\t\tkept: withoutReplayedReasoning([prompt, ...context.slice(cut)]),\n\t};\n}\n\n/**\n * Copies of `messages` without what only a provider reads back, which a changed context before\n * it may no longer accept: its signed reasoning and its compactions. The last answer keeps its\n * reasoning, since the provider needs it to go on with that answer's tool calls.\n */\nexport function withoutReplayedReasoning(messages: Message[]): Message[] {\n\tconst last = messages.findLastIndex(message => message.role === 'assistant');\n\treturn messages.flatMap((message, index) => {\n\t\tconst content = structuredClone(message.content).filter(\n\t\t\tpart =>\n\t\t\t\tpart.type !== 'compaction' &&\n\t\t\t\tpart.type !== 'contextSummary' &&\n\t\t\t\t(part.type !== 'providerData' || index === last),\n\t\t);\n\t\treturn content.length > 0 ? [{ role: message.role, content }] : [];\n\t});\n}\n\n/** What the recent compactor keeps, as its summary requests tell the model. */\nexport function recentlyKept(keptSteps: number): string {\n\treturn `the user's request for this run and your last ${String(keptSteps)} answers, with their tool results,`;\n}\n\n/**\n * What the model is asked once its context passed the threshold, after that whole context.\n *\n * @param kept what is kept word for word after the summary, as `recentlyKept` says it.\n */\nexport function summaryRequest(kept: string): Message {\n\treturn {\n\t\trole: 'user',\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: 'text',\n\t\t\t\ttext: `Your context is about to be compacted. Write a summary of this conversation so far that lets you go on without it: what the user asked, what you found and decided, what you changed and why, what is left to do, and what you would need to read again. After your summary, only ${kept} are kept word for word, and not your reasoning in them: say what you were thinking and planning there too. Answer with the summary alone, in plain text, and call no tool.`,\n\t\t\t},\n\t\t],\n\t};\n}\n\n// A context that no longer fits cannot be sent again to ask for its summary, so the model reads\n// it as text, bounded to fit any model the harness offers.\nconst transcriptCharacters = 300_000;\nconst partCharacters = 3_000;\n\n/**\n * What the model is asked when the context no longer fit: the part to summarize, as text, since\n * it can no longer be sent as it was.\n */\nexport function transcriptSummaryRequest(summarized: Message[], kept: string): Message {\n\treturn {\n\t\trole: 'user',\n\t\tcontent: [\n\t\t\t{\n\t\t\t\ttype: 'text',\n\t\t\t\ttext: [\n\t\t\t\t\t`Below is the start of a conversation in which you, an agent, work on a task for the user. It no longer fits your context, so write a summary of it that lets you go on without it: what the user asked, what you found and decided, what you changed and why, what is left to do, and what you would need to read again. Only ${kept} are kept word for word after your summary. Answer with the summary alone, in plain text.`,\n\t\t\t\t\t'',\n\t\t\t\t\tmessagesAsText(summarized, transcriptCharacters, partCharacters),\n\t\t\t\t].join('\\n'),\n\t\t\t},\n\t\t],\n\t};\n}\n\n/**\n * `messages` as plain text, for a model to summarize: who said or did what, each part cut at\n * `partChars`, and the whole at `maxChars`, marked when cut.\n */\nexport function messagesAsText(messages: Message[], maxChars: number, partChars: number): string {\n\tconst lines = messages.flatMap(message =>\n\t\tmessage.content.flatMap(part => partAsText(message, part, partChars)),\n\t);\n\tconst text = lines.join('\\n');\n\treturn text.length <= maxChars ? text : `${text.slice(0, maxChars)}\\n[the rest was cut]`;\n}\n\nfunction partAsText(message: Message, part: MessagePart, partChars: number): string[] {\n\tswitch (part.type) {\n\t\tcase 'text':\n\t\t\treturn [`${message.role}: ${shorten(part.text, partChars)}`];\n\t\tcase 'toolCall':\n\t\t\treturn [\n\t\t\t\t`${message.role} called ${part.name} ${shorten(JSON.stringify(part.input), partChars)}`,\n\t\t\t];\n\t\tcase 'toolResult':\n\t\t\treturn [\n\t\t\t\t`${part.name} ${part.isError ? 'failed' : 'returned'}: ${shorten(part.output, partChars)}`,\n\t\t\t];\n\t\t// Readable reasoning, signed blocks and summaries only a provider reads are not the work.\n\t\tcase 'reasoning':\n\t\tcase 'providerData':\n\t\tcase 'compaction':\n\t\tcase 'contextSummary':\n\t\t\treturn [];\n\t}\n}\n\nfunction shorten(text: string, max: number): string {\n\treturn text.length <= max ? text : `${text.slice(0, max)}…`;\n}\n"]}
|
|
@@ -39,6 +39,9 @@ export function measureContext({ instructions, providerDataSource, tools, contex
|
|
|
39
39
|
measured.reasoning += JSON.stringify(part.data).length;
|
|
40
40
|
}
|
|
41
41
|
break;
|
|
42
|
+
// The agent sends the context derived from it, where the summary is text.
|
|
43
|
+
case 'contextSummary':
|
|
44
|
+
break;
|
|
42
45
|
case 'toolCall':
|
|
43
46
|
calls.set(part.id, part);
|
|
44
47
|
measured.toolCalls += JSON.stringify(part.input).length;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stepContext.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/stepContext.ts"],"names":[],"mappings":"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","sourcesContent":["import type { StepContext } from '#src/agent/domain/agent';\nimport { type Message, sentContext, type ToolCallPart } from '#src/llm/domain/message';\nimport type { ToolDefinition } from '#src/llm/domain/tool';\n\n// Enough to name what fills a context without listing every result of a long run.\nconst largestResults = 5;\n\n/**\n * What one call to the model sends, in characters, by where it comes from. Characters, not\n * tokens: the harness does not tokenize, and the provider's count arrives with the answer.\n * `summarize` names a tool call as its tool does, or gives nothing.\n */\nexport function measureContext({\n\tinstructions,\n\tproviderDataSource,\n\ttools,\n\tcontext,\n\tsummarize,\n}: {\n\tinstructions: string | undefined;\n\t/** The only `providerData` the client sends back; every other is dropped. */\n\tproviderDataSource: string | undefined;\n\ttools: ToolDefinition[];\n\tcontext: Message[];\n\tsummarize: (call: ToolCallPart) => string | undefined;\n}): StepContext {\n\tconst measured: StepContext = {\n\t\tinstructions: instructions?.length ?? 0,\n\t\ttools: JSON.stringify(tools).length,\n\t\tmessages: 0,\n\t\treasoning: 0,\n\t\ttoolCalls: 0,\n\t\ttoolResults: [],\n\t\tlargestResults: [],\n\t};\n\tconst calls = new Map<string, ToolCallPart>();\n\tconst byTool = new Map<string, { characters: number; calls: number }>();\n\tconst results: StepContext['largestResults'] = [];\n\n\tfor (const part of sentContext(context, providerDataSource).flatMap(message => message.content)) {\n\t\tswitch (part.type) {\n\t\t\tcase 'text':\n\t\t\t\tmeasured.messages += part.text.length;\n\t\t\t\tbreak;\n\t\t\t// The provider's summary of the turns before it stands in for their messages.\n\t\t\tcase 'compaction':\n\t\t\t\tif (part.source === providerDataSource) {\n\t\t\t\t\tmeasured.messages += JSON.stringify(part.data).length;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t// Readable reasoning is never sent back: only the provider's own blocks are, which\n\t\t\t// already hold it, signed or encrypted.\n\t\t\tcase 'reasoning':\n\t\t\t\tbreak;\n\t\t\tcase 'providerData':\n\t\t\t\tif (providerDataSource === undefined || part.source === providerDataSource) {\n\t\t\t\t\tmeasured.reasoning += JSON.stringify(part.data).length;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase 'toolCall':\n\t\t\t\tcalls.set(part.id, part);\n\t\t\t\tmeasured.toolCalls += JSON.stringify(part.input).length;\n\t\t\t\tbreak;\n\t\t\tcase 'toolResult': {\n\t\t\t\tconst characters = part.output.length;\n\t\t\t\tconst tool = byTool.get(part.name) ?? { characters: 0, calls: 0 };\n\t\t\t\ttool.characters += characters;\n\t\t\t\ttool.calls += 1;\n\t\t\t\tbyTool.set(part.name, tool);\n\t\t\t\tconst call = calls.get(part.callId);\n\t\t\t\tconst summary = call && summarize(call);\n\t\t\t\tresults.push({ tool: part.name, characters, ...(summary ? { summary } : {}) });\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tmeasured.toolResults = [...byTool]\n\t\t.map(([tool, { characters, calls }]) => ({ tool, characters, calls }))\n\t\t.sort((a, b) => b.characters - a.characters);\n\tmeasured.largestResults = results\n\t\t.sort((a, b) => b.characters - a.characters)\n\t\t.slice(0, largestResults);\n\treturn measured;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"stepContext.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/stepContext.ts"],"names":[],"mappings":"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","sourcesContent":["import type { StepContext } from '#src/agent/domain/agent';\nimport { type Message, sentContext, type ToolCallPart } from '#src/llm/domain/message';\nimport type { ToolDefinition } from '#src/llm/domain/tool';\n\n// Enough to name what fills a context without listing every result of a long run.\nconst largestResults = 5;\n\n/**\n * What one call to the model sends, in characters, by where it comes from. Characters, not\n * tokens: the harness does not tokenize, and the provider's count arrives with the answer.\n * `summarize` names a tool call as its tool does, or gives nothing.\n */\nexport function measureContext({\n\tinstructions,\n\tproviderDataSource,\n\ttools,\n\tcontext,\n\tsummarize,\n}: {\n\tinstructions: string | undefined;\n\t/** The only `providerData` the client sends back; every other is dropped. */\n\tproviderDataSource: string | undefined;\n\ttools: ToolDefinition[];\n\tcontext: Message[];\n\tsummarize: (call: ToolCallPart) => string | undefined;\n}): StepContext {\n\tconst measured: StepContext = {\n\t\tinstructions: instructions?.length ?? 0,\n\t\ttools: JSON.stringify(tools).length,\n\t\tmessages: 0,\n\t\treasoning: 0,\n\t\ttoolCalls: 0,\n\t\ttoolResults: [],\n\t\tlargestResults: [],\n\t};\n\tconst calls = new Map<string, ToolCallPart>();\n\tconst byTool = new Map<string, { characters: number; calls: number }>();\n\tconst results: StepContext['largestResults'] = [];\n\n\tfor (const part of sentContext(context, providerDataSource).flatMap(message => message.content)) {\n\t\tswitch (part.type) {\n\t\t\tcase 'text':\n\t\t\t\tmeasured.messages += part.text.length;\n\t\t\t\tbreak;\n\t\t\t// The provider's summary of the turns before it stands in for their messages.\n\t\t\tcase 'compaction':\n\t\t\t\tif (part.source === providerDataSource) {\n\t\t\t\t\tmeasured.messages += JSON.stringify(part.data).length;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t// Readable reasoning is never sent back: only the provider's own blocks are, which\n\t\t\t// already hold it, signed or encrypted.\n\t\t\tcase 'reasoning':\n\t\t\t\tbreak;\n\t\t\tcase 'providerData':\n\t\t\t\tif (providerDataSource === undefined || part.source === providerDataSource) {\n\t\t\t\t\tmeasured.reasoning += JSON.stringify(part.data).length;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t// The agent sends the context derived from it, where the summary is text.\n\t\t\tcase 'contextSummary':\n\t\t\t\tbreak;\n\t\t\tcase 'toolCall':\n\t\t\t\tcalls.set(part.id, part);\n\t\t\t\tmeasured.toolCalls += JSON.stringify(part.input).length;\n\t\t\t\tbreak;\n\t\t\tcase 'toolResult': {\n\t\t\t\tconst characters = part.output.length;\n\t\t\t\tconst tool = byTool.get(part.name) ?? { characters: 0, calls: 0 };\n\t\t\t\ttool.characters += characters;\n\t\t\t\ttool.calls += 1;\n\t\t\t\tbyTool.set(part.name, tool);\n\t\t\t\tconst call = calls.get(part.callId);\n\t\t\t\tconst summary = call && summarize(call);\n\t\t\t\tresults.push({ tool: part.name, characters, ...(summary ? { summary } : {}) });\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\tmeasured.toolResults = [...byTool]\n\t\t.map(([tool, { characters, calls }]) => ({ tool, characters, calls }))\n\t\t.sort((a, b) => b.characters - a.characters);\n\tmeasured.largestResults = results\n\t\t.sort((a, b) => b.characters - a.characters)\n\t\t.slice(0, largestResults);\n\treturn measured;\n}\n"]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TriagedResult } from '#src/llm/domain/contextTriage';
|
|
2
|
+
import type { Message } from '#src/llm/domain/message';
|
|
3
|
+
/** A tool result before the model's last answer, which a triage judges. */
|
|
4
|
+
export interface TriageCandidate extends TriagedResult {
|
|
5
|
+
callId: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The tool results of `context` a triage judges: each one before the model's last answer whose
|
|
9
|
+
* call is there too. The last answer's own results are always kept.
|
|
10
|
+
*/
|
|
11
|
+
export declare function triageCandidates(context: Message[]): TriageCandidate[];
|
|
12
|
+
/**
|
|
13
|
+
* Where a triaged compaction cuts `context`: the run's prompt, each tool call the triage kept
|
|
14
|
+
* with its result, and the model's last answer with everything after it are kept word for word,
|
|
15
|
+
* in their order; everything else is summarized. `undefined` when the model has answered once
|
|
16
|
+
* at most, or the triage kept every answer whole, so a summary would replace nothing.
|
|
17
|
+
*
|
|
18
|
+
* @param prompt the run's prompt, found by identity as in `recentCut`; when it is not there, it
|
|
19
|
+
* is kept first.
|
|
20
|
+
* @param kept the ids of the tool calls kept with their results.
|
|
21
|
+
*/
|
|
22
|
+
export declare function triagedCut(context: Message[], prompt: Message, kept: ReadonlySet<string>): {
|
|
23
|
+
summarized: Message[];
|
|
24
|
+
kept: Message[];
|
|
25
|
+
} | undefined;
|
|
26
|
+
/** What a triaged compaction keeps, as its summary requests tell the model. */
|
|
27
|
+
export declare function triagedKept(kept: TriageCandidate[]): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { withoutReplayedReasoning } from "./recentCompaction.js";
|
|
2
|
+
/**
|
|
3
|
+
* The tool results of `context` a triage judges: each one before the model's last answer whose
|
|
4
|
+
* call is there too. The last answer's own results are always kept.
|
|
5
|
+
*/
|
|
6
|
+
export function triageCandidates(context) {
|
|
7
|
+
const last = context.findLastIndex(message => message.role === 'assistant');
|
|
8
|
+
const before = context.slice(0, Math.max(last, 0));
|
|
9
|
+
const calls = new Map(before.flatMap(message => message.content.flatMap(part => (part.type === 'toolCall' ? [[part.id, part]] : []))));
|
|
10
|
+
return before.flatMap(message => message.content.flatMap(part => {
|
|
11
|
+
const call = part.type === 'toolResult' ? calls.get(part.callId) : undefined;
|
|
12
|
+
return part.type === 'toolResult' && call
|
|
13
|
+
? [{ callId: part.callId, tool: call.name, input: call.input, output: part.output }]
|
|
14
|
+
: [];
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Where a triaged compaction cuts `context`: the run's prompt, each tool call the triage kept
|
|
19
|
+
* with its result, and the model's last answer with everything after it are kept word for word,
|
|
20
|
+
* in their order; everything else is summarized. `undefined` when the model has answered once
|
|
21
|
+
* at most, or the triage kept every answer whole, so a summary would replace nothing.
|
|
22
|
+
*
|
|
23
|
+
* @param prompt the run's prompt, found by identity as in `recentCut`; when it is not there, it
|
|
24
|
+
* is kept first.
|
|
25
|
+
* @param kept the ids of the tool calls kept with their results.
|
|
26
|
+
*/
|
|
27
|
+
export function triagedCut(context, prompt, kept) {
|
|
28
|
+
const last = context.findLastIndex(message => message.role === 'assistant');
|
|
29
|
+
const found = context.indexOf(prompt);
|
|
30
|
+
const isKept = (part) => (part.type === 'toolCall' && kept.has(part.id)) ||
|
|
31
|
+
(part.type === 'toolResult' && kept.has(part.callId));
|
|
32
|
+
const keptMessages = found === -1 ? [prompt] : [];
|
|
33
|
+
const summarized = [];
|
|
34
|
+
context.slice(0, Math.max(last, 0)).forEach((message, index) => {
|
|
35
|
+
if (index === found) {
|
|
36
|
+
keptMessages.push(prompt);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const keptParts = message.content.filter(isKept);
|
|
40
|
+
const rest = message.content.filter(part => !isKept(part));
|
|
41
|
+
if (keptParts.length > 0)
|
|
42
|
+
keptMessages.push({ role: message.role, content: keptParts });
|
|
43
|
+
if (rest.length > 0)
|
|
44
|
+
summarized.push({ role: message.role, content: rest });
|
|
45
|
+
});
|
|
46
|
+
if (!summarized.some(message => message.role === 'assistant'))
|
|
47
|
+
return undefined;
|
|
48
|
+
return { summarized, kept: withoutReplayedReasoning([...keptMessages, ...context.slice(last)]) };
|
|
49
|
+
}
|
|
50
|
+
/** What a triaged compaction keeps, as its summary requests tell the model. */
|
|
51
|
+
export function triagedKept(kept) {
|
|
52
|
+
const calls = kept.map(({ tool, input }) => `${tool} ${shorten(JSON.stringify(input), 200)}`);
|
|
53
|
+
return calls.length === 0
|
|
54
|
+
? "the user's request for this run and your last answer, with its tool results,"
|
|
55
|
+
: `the user's request for this run, your last answer with its tool results, and the results of these earlier tool calls: ${calls.join('; ')},`;
|
|
56
|
+
}
|
|
57
|
+
function shorten(text, max) {
|
|
58
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=triagedCompaction.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"triagedCompaction.js","sourceRoot":"","sources":["../../../../src/engines/domain/model/triagedCompaction.ts"],"names":[],"mappings":"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","sourcesContent":["import type { TriagedResult } from '#src/llm/domain/contextTriage';\nimport type { Message, MessagePart } from '#src/llm/domain/message';\nimport { withoutReplayedReasoning } from './recentCompaction.ts';\n\n/** A tool result before the model's last answer, which a triage judges. */\nexport interface TriageCandidate extends TriagedResult {\n\tcallId: string;\n}\n\n/**\n * The tool results of `context` a triage judges: each one before the model's last answer whose\n * call is there too. The last answer's own results are always kept.\n */\nexport function triageCandidates(context: Message[]): TriageCandidate[] {\n\tconst last = context.findLastIndex(message => message.role === 'assistant');\n\tconst before = context.slice(0, Math.max(last, 0));\n\tconst calls = new Map(\n\t\tbefore.flatMap(message =>\n\t\t\tmessage.content.flatMap(part => (part.type === 'toolCall' ? [[part.id, part] as const] : [])),\n\t\t),\n\t);\n\treturn before.flatMap(message =>\n\t\tmessage.content.flatMap(part => {\n\t\t\tconst call = part.type === 'toolResult' ? calls.get(part.callId) : undefined;\n\t\t\treturn part.type === 'toolResult' && call\n\t\t\t\t? [{ callId: part.callId, tool: call.name, input: call.input, output: part.output }]\n\t\t\t\t: [];\n\t\t}),\n\t);\n}\n\n/**\n * Where a triaged compaction cuts `context`: the run's prompt, each tool call the triage kept\n * with its result, and the model's last answer with everything after it are kept word for word,\n * in their order; everything else is summarized. `undefined` when the model has answered once\n * at most, or the triage kept every answer whole, so a summary would replace nothing.\n *\n * @param prompt the run's prompt, found by identity as in `recentCut`; when it is not there, it\n * is kept first.\n * @param kept the ids of the tool calls kept with their results.\n */\nexport function triagedCut(\n\tcontext: Message[],\n\tprompt: Message,\n\tkept: ReadonlySet<string>,\n): { summarized: Message[]; kept: Message[] } | undefined {\n\tconst last = context.findLastIndex(message => message.role === 'assistant');\n\tconst found = context.indexOf(prompt);\n\tconst isKept = (part: MessagePart) =>\n\t\t(part.type === 'toolCall' && kept.has(part.id)) ||\n\t\t(part.type === 'toolResult' && kept.has(part.callId));\n\tconst keptMessages: Message[] = found === -1 ? [prompt] : [];\n\tconst summarized: Message[] = [];\n\tcontext.slice(0, Math.max(last, 0)).forEach((message, index) => {\n\t\tif (index === found) {\n\t\t\tkeptMessages.push(prompt);\n\t\t\treturn;\n\t\t}\n\t\tconst keptParts = message.content.filter(isKept);\n\t\tconst rest = message.content.filter(part => !isKept(part));\n\t\tif (keptParts.length > 0) keptMessages.push({ role: message.role, content: keptParts });\n\t\tif (rest.length > 0) summarized.push({ role: message.role, content: rest });\n\t});\n\tif (!summarized.some(message => message.role === 'assistant')) return undefined;\n\treturn { summarized, kept: withoutReplayedReasoning([...keptMessages, ...context.slice(last)]) };\n}\n\n/** What a triaged compaction keeps, as its summary requests tell the model. */\nexport function triagedKept(kept: TriageCandidate[]): string {\n\tconst calls = kept.map(({ tool, input }) => `${tool} ${shorten(JSON.stringify(input), 200)}`);\n\treturn calls.length === 0\n\t\t? \"the user's request for this run and your last answer, with its tool results,\"\n\t\t: `the user's request for this run, your last answer with its tool results, and the results of these earlier tool calls: ${calls.join('; ')},`;\n}\n\nfunction shorten(text: string, max: number): string {\n\treturn text.length <= max ? text : `${text.slice(0, max)}…`;\n}\n"]}
|
|
@@ -55,12 +55,27 @@ export interface CreateLLMAgentOptions {
|
|
|
55
55
|
*/
|
|
56
56
|
maxSteps?: number;
|
|
57
57
|
/**
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
* The tokens of context at which the provider compacts it on its side: it replaces every turn
|
|
59
|
+
* before that call with its own summary, which only its model reads back. The conversation
|
|
60
|
+
* keeps every original turn. Defaults to the model's threshold: 450,000 for Claude Opus,
|
|
61
|
+
* Fable and Sonnet and GPT astra, sol and terra, and 125,000 for Claude Haiku and GPT luna.
|
|
62
|
+
* `false` turns compaction off. Either way, a context that no longer fits the model is
|
|
63
|
+
* compacted once, and the call tried again. At least 50,000 on Anthropic.
|
|
64
|
+
*/
|
|
65
|
+
compactAt?: number | false;
|
|
66
|
+
/**
|
|
67
|
+
* Who compacts the context once it passes `compactAt`. `provider`, the default, has the
|
|
68
|
+
* model's provider compact it on its side. `recent` has the agent's own model summarize it
|
|
69
|
+
* before the next call, from its provider's cache, and keeps the run's prompt and the run's
|
|
70
|
+
* last 10 answers, each with its tool results, word for word after the summary, without
|
|
71
|
+
* their signed reasoning. Earlier runs are always summarized. `jev` summarizes the same way,
|
|
72
|
+
* but TypeSafe's Jev chooses what is kept: the run's prompt, the model's last answer with its
|
|
73
|
+
* tool results, and each earlier tool result Jev judges the agent still needs word for word,
|
|
74
|
+
* with its call. Jev is sent the run's prompt, the model's last answer and each of those tool
|
|
75
|
+
* results, so what the tools read leaves for TypeSafe. It reads the key from `TYPESAFE_API_KEY`; without it, or when Jev fails, the
|
|
76
|
+
* `recent` choice is made instead, and logged.
|
|
77
|
+
*/
|
|
78
|
+
compaction?: CompactionMode;
|
|
64
79
|
/**
|
|
65
80
|
* Runs every tool call without asking, `destructive` ones included, for CI where no one can
|
|
66
81
|
* answer. Without it, a `destructive` call goes to the run's `approve`, or is denied.
|
|
@@ -74,10 +89,14 @@ export interface CreateLLMAgentOptions {
|
|
|
74
89
|
*/
|
|
75
90
|
onTranscript?: TranscriptCallback;
|
|
76
91
|
}
|
|
92
|
+
/** Who compacts an agent's context; see `CreateLLMAgentOptions.compaction`. */
|
|
93
|
+
export type CompactionMode = 'provider' | 'recent' | 'jev';
|
|
77
94
|
/** A limit per role of `createLLMOrchestrator`; a role left out keeps its default. */
|
|
78
95
|
export type RoleSteps = Partial<Record<OrchestratorRole, number>>;
|
|
79
96
|
/** A number of tokens per role of `createLLMOrchestrator`; a role left out keeps its default. */
|
|
80
97
|
export type RoleTokens = Partial<Record<OrchestratorRole, number>>;
|
|
98
|
+
/** The consumer's agents, from `agentAsTool`, that each role of `createLLMOrchestrator` may call. */
|
|
99
|
+
export type RoleAgents = Partial<Record<OrchestratorRole, DelegationTool[]>>;
|
|
81
100
|
type OrchestratorRole = CommandRole;
|
|
82
101
|
export interface CreateLLMOrchestratorOptions extends CreateOrchestratorOptions {
|
|
83
102
|
/**
|
|
@@ -94,11 +113,14 @@ export interface CreateLLMOrchestratorOptions extends CreateOrchestratorOptions
|
|
|
94
113
|
*/
|
|
95
114
|
maxSteps?: number | RoleSteps;
|
|
96
115
|
/**
|
|
97
|
-
* The
|
|
98
|
-
* `CreateLLMAgentOptions.compactAt`: one number for every role, or a number per role.
|
|
99
|
-
*
|
|
116
|
+
* The tokens of context at which each role's provider compacts it, as
|
|
117
|
+
* `CreateLLMAgentOptions.compactAt`: one number for every role, or a number per role. A role
|
|
118
|
+
* left out compacts at its model's threshold, as do the explorers. `false` turns compaction
|
|
119
|
+
* off for every role.
|
|
100
120
|
*/
|
|
101
|
-
compactAt?: number | RoleTokens;
|
|
121
|
+
compactAt?: number | false | RoleTokens;
|
|
122
|
+
/** Who compacts every agent's context, as `CreateLLMAgentOptions.compaction`. */
|
|
123
|
+
compaction?: CompactionMode;
|
|
102
124
|
/**
|
|
103
125
|
* Commands the roles may run, such as the project's tests, through `runCommand`. Each goes to
|
|
104
126
|
* the roles its `roles` names, the executor by default. See `createFileTools`. Each needs a
|
|
@@ -125,6 +147,12 @@ export interface CreateLLMOrchestratorOptions extends CreateOrchestratorOptions
|
|
|
125
147
|
* root is in when the user asks. See `createFileTools`.
|
|
126
148
|
*/
|
|
127
149
|
removeWorktree?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* The consumer's agents, from `agentAsTool`, that each role may hand a task to, beside its own
|
|
152
|
+
* tools: the planner and the reviewer keep their explorers. An agent never writes more than
|
|
153
|
+
* the role that calls it, so only the executor's may write, within the `write` roots.
|
|
154
|
+
*/
|
|
155
|
+
agents?: RoleAgents;
|
|
128
156
|
}
|
|
129
157
|
/**
|
|
130
158
|
* Builds an agent whose conversation MiKode owns, on a provider's model API, already wrapped in
|
|
@@ -138,7 +166,7 @@ export interface CreateLLMOrchestratorOptions extends CreateOrchestratorOptions
|
|
|
138
166
|
* `OPENAI_API_KEY` is missing. The Anthropic SDK resolves credentials lazily, so a missing
|
|
139
167
|
* `ANTHROPIC_API_KEY` fails the first run instead, with an `UnrecoverableError`.
|
|
140
168
|
*/
|
|
141
|
-
export declare function createLLMAgent(provider: AgentProvider, { model, reasoningEffort, systemPrompt, tools, workspace, summarizeUndone, maxSteps, compactAt, autoApprove, logger, onTranscript, }: CreateLLMAgentOptions): Agent;
|
|
169
|
+
export declare function createLLMAgent(provider: AgentProvider, { model, reasoningEffort, systemPrompt, tools, workspace, summarizeUndone, maxSteps, compactAt, compaction, autoApprove, logger, onTranscript, }: CreateLLMAgentOptions): Agent;
|
|
142
170
|
/**
|
|
143
171
|
* The file tools for an agent from `createLLMAgent` on `provider`, over `workspace`, in the
|
|
144
172
|
* provider's own format:
|
|
@@ -211,5 +239,5 @@ export declare function createFileTools(provider: AgentProvider, workspace: Work
|
|
|
211
239
|
* @throws {UnrecoverableError} when a role cannot be set up, for example because
|
|
212
240
|
* `OPENAI_API_KEY` is missing, or when no program can read the repository.
|
|
213
241
|
*/
|
|
214
|
-
export declare function createLLMOrchestrator({ provider, autoApprove, systemPrompts, workspace, maxSteps, compactAt, commands, formatter, summarizeUndone, logger, onTranscript, removeWorktree, }?: CreateLLMOrchestratorOptions): Promise<Agent>;
|
|
242
|
+
export declare function createLLMOrchestrator({ provider, autoApprove, systemPrompts, workspace, maxSteps, compactAt, compaction: compactionMode, commands, formatter, summarizeUndone, logger, onTranscript, removeWorktree, agents, }?: CreateLLMOrchestratorOptions): Promise<Agent>;
|
|
215
243
|
export {};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
+
import { recentKeptSteps } from '#src/engines/domain/model/compactor';
|
|
2
3
|
import { LLMAgent } from '#src/engines/domain/model/llmAgent';
|
|
4
|
+
import { JevContextTriage } from '#src/llm/infrastructure/jevContextTriage';
|
|
3
5
|
import { ClaudeLLMClient, claudeLLMModels, claudeLLMReasoningEfforts, } from '#src/llm/infrastructure/claudeLLMClient';
|
|
4
6
|
import { OpenAILLMClient, openAIModels, openAIReasoningEfforts, } from '#src/llm/infrastructure/openAILLMClient';
|
|
5
7
|
import { executorInstructions, executorInstructionsWithoutCommands, OrchestratorAgent, plannerInstructions, reviewerInstructions, reviewerInstructionsWithoutCommands, } from '#src/orchestration/domain/model/orchestratorAgent';
|
|
@@ -36,13 +38,14 @@ export function isLLMModelOf(provider, value) {
|
|
|
36
38
|
export function isLLMReasoningEffortOf(provider, value) {
|
|
37
39
|
return isOneOf(llmReasoningEfforts[provider], value);
|
|
38
40
|
}
|
|
41
|
+
const compactionModes = ['provider', 'recent', 'jev'];
|
|
39
42
|
// The executor reads, edits and checks, so it needs more calls than the roles that only read.
|
|
40
43
|
const defaultRoleSteps = {
|
|
41
44
|
planner: 65,
|
|
42
45
|
executor: 80,
|
|
43
46
|
reviewer: 65,
|
|
44
47
|
};
|
|
45
|
-
//
|
|
48
|
+
// Each role compacts at its model's threshold unless told otherwise.
|
|
46
49
|
const defaultRoleCompaction = {
|
|
47
50
|
planner: undefined,
|
|
48
51
|
executor: undefined,
|
|
@@ -76,24 +79,68 @@ const defaultLLMAgentModels = {
|
|
|
76
79
|
anthropic: 'sonnet',
|
|
77
80
|
openai: 'gpt-5.6-luna',
|
|
78
81
|
};
|
|
82
|
+
// The tokens of context at which each model compacts by default, the small ones sooner. The
|
|
83
|
+
// margins are wide on purpose: compacting at 64k cleared file reads the executor then read again.
|
|
84
|
+
const compactionThresholds = {
|
|
85
|
+
opus: 450_000,
|
|
86
|
+
fable: 450_000,
|
|
87
|
+
sonnet: 450_000,
|
|
88
|
+
haiku: 125_000,
|
|
89
|
+
'gpt-6-astra': 450_000,
|
|
90
|
+
'gpt-5.6-sol': 450_000,
|
|
91
|
+
'gpt-5.6-terra': 450_000,
|
|
92
|
+
'gpt-5.6-luna': 125_000,
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The compactor `mode` names for an agent on `model`: at `compactAt`, at the model's threshold
|
|
96
|
+
* without it, or off with `false`, when only a context that no longer fits is compacted.
|
|
97
|
+
*
|
|
98
|
+
* @throws {InvalidAgentConfigError} for a mode that does not exist, from untyped input.
|
|
99
|
+
*/
|
|
100
|
+
function compactorFor(model, compactAt, mode = 'provider') {
|
|
101
|
+
// Checked whatever it is, as untyped input can hold anything here.
|
|
102
|
+
if (typeof mode !== 'string' || !isOneOf(compactionModes, mode)) {
|
|
103
|
+
throw new InvalidAgentConfigError(`compaction must be one of: ${compactionModes.join(', ')}; got ${String(mode)}`);
|
|
104
|
+
}
|
|
105
|
+
// A model with no threshold was already refused by its client.
|
|
106
|
+
const at = compactAt === false
|
|
107
|
+
? undefined
|
|
108
|
+
: (compactAt ?? compactionThresholds[model]);
|
|
109
|
+
switch (mode) {
|
|
110
|
+
case 'provider':
|
|
111
|
+
return { by: 'provider', at };
|
|
112
|
+
case 'recent':
|
|
113
|
+
return { by: 'harness', at, keptSteps: recentKeptSteps };
|
|
114
|
+
case 'jev':
|
|
115
|
+
return { by: 'harness', at, keptSteps: recentKeptSteps, triage: new JevContextTriage() };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function defaultModelOf(provider) {
|
|
119
|
+
switch (provider) {
|
|
120
|
+
case 'anthropic':
|
|
121
|
+
case 'openai':
|
|
122
|
+
return defaultLLMAgentModels[provider];
|
|
123
|
+
default:
|
|
124
|
+
// Reachable from untyped input.
|
|
125
|
+
throw unknownProvider(provider);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
79
128
|
function unknownProvider(provider) {
|
|
80
129
|
return new InvalidAgentConfigError(`"${String(provider)}" is not an agent provider; expected one of: ${agentProviders.join(', ')}`);
|
|
81
130
|
}
|
|
82
|
-
function llmClientFor(provider, { model, reasoningEffort,
|
|
131
|
+
function llmClientFor(provider, { model, reasoningEffort, systemPrompt, logger, }) {
|
|
83
132
|
switch (provider) {
|
|
84
133
|
case 'anthropic':
|
|
85
134
|
return new ClaudeLLMClient({
|
|
86
|
-
model
|
|
135
|
+
model,
|
|
87
136
|
reasoningEffort,
|
|
88
|
-
compactAt,
|
|
89
137
|
systemPrompt,
|
|
90
138
|
logger,
|
|
91
139
|
});
|
|
92
140
|
case 'openai':
|
|
93
141
|
return new OpenAILLMClient({
|
|
94
|
-
model
|
|
142
|
+
model,
|
|
95
143
|
reasoningEffort,
|
|
96
|
-
compactAt,
|
|
97
144
|
systemPrompt,
|
|
98
145
|
logger,
|
|
99
146
|
});
|
|
@@ -159,11 +206,11 @@ function withGuidance(systemPrompt, guidance) {
|
|
|
159
206
|
return guidance === '' ? systemPrompt : `${systemPrompt}\n\n${guidance}`;
|
|
160
207
|
}
|
|
161
208
|
/** An `LLMAgent` on `provider`, wrapped in the harness's retry policy. */
|
|
162
|
-
function buildLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools, history, summarizer, maxSteps, compactAt, autoApprove, logger, onTranscript, }) {
|
|
209
|
+
function buildLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools, history, summarizer, maxSteps, compactAt, compaction, autoApprove, logger, onTranscript, writeRoots, }) {
|
|
210
|
+
const chosen = model ?? defaultModelOf(provider);
|
|
163
211
|
const llmClient = llmClientFor(provider, {
|
|
164
|
-
model,
|
|
212
|
+
model: chosen,
|
|
165
213
|
reasoningEffort,
|
|
166
|
-
compactAt,
|
|
167
214
|
systemPrompt,
|
|
168
215
|
logger,
|
|
169
216
|
});
|
|
@@ -178,6 +225,8 @@ function buildLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools,
|
|
|
178
225
|
...(summarizer ? { summarizer } : {}),
|
|
179
226
|
...(maxSteps === undefined ? {} : { maxSteps }),
|
|
180
227
|
...(onTranscript ? { onTranscript } : {}),
|
|
228
|
+
...(writeRoots ? { writeRoots } : {}),
|
|
229
|
+
compactor: compactorFor(chosen, compactAt, compaction),
|
|
181
230
|
}),
|
|
182
231
|
logger,
|
|
183
232
|
noteFailures: false,
|
|
@@ -195,7 +244,7 @@ function buildLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools,
|
|
|
195
244
|
* `OPENAI_API_KEY` is missing. The Anthropic SDK resolves credentials lazily, so a missing
|
|
196
245
|
* `ANTHROPIC_API_KEY` fails the first run instead, with an `UnrecoverableError`.
|
|
197
246
|
*/
|
|
198
|
-
export function createLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools = [], workspace, summarizeUndone = true, maxSteps, compactAt, autoApprove, logger = new Logger(), onTranscript, }) {
|
|
247
|
+
export function createLLMAgent(provider, { model, reasoningEffort, systemPrompt, tools = [], workspace, summarizeUndone = true, maxSteps, compactAt, compaction, autoApprove, logger = new Logger(), onTranscript, }) {
|
|
199
248
|
// Told where to work with nothing to work with, the model would only pretend: it writes its
|
|
200
249
|
// tool calls as text, and the run returns them as its answer.
|
|
201
250
|
if (workspace && tools.length === 0) {
|
|
@@ -213,11 +262,17 @@ export function createLLMAgent(provider, { model, reasoningEffort, systemPrompt,
|
|
|
213
262
|
...(workspace && summarizeUndone ? { summarizer: summarizerFor(provider, logger) } : {}),
|
|
214
263
|
maxSteps,
|
|
215
264
|
compactAt,
|
|
265
|
+
compaction,
|
|
216
266
|
autoApprove,
|
|
217
267
|
logger,
|
|
218
268
|
onTranscript,
|
|
269
|
+
// An agent it delegates to writes only where its workspace lets it.
|
|
270
|
+
...(workspace ? { writeRoots: writeRootsOf(workspace.roots) } : {}),
|
|
219
271
|
});
|
|
220
272
|
}
|
|
273
|
+
function writeRootsOf(roots) {
|
|
274
|
+
return roots.filter(({ access }) => access === 'write').map(({ path }) => path);
|
|
275
|
+
}
|
|
221
276
|
/**
|
|
222
277
|
* The file tools for an agent from `createLLMAgent` on `provider`, over `workspace`, in the
|
|
223
278
|
* provider's own format:
|
|
@@ -285,6 +340,32 @@ async function removeWorktreeToolFor({ base, roots, stateDirectory, }) {
|
|
|
285
340
|
});
|
|
286
341
|
return createRemoveWorktreeTool({ base, roots, stateDirectory });
|
|
287
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Each role's agents from `agentAsTool`, as the harness's tools.
|
|
345
|
+
*
|
|
346
|
+
* @throws {InvalidAgentConfigError} for a role that does not exist, or a role's agents that are
|
|
347
|
+
* not a list.
|
|
348
|
+
*/
|
|
349
|
+
function roleAgents(agents) {
|
|
350
|
+
// Untyped input, such as the CLI's configuration file, can hold anything here.
|
|
351
|
+
if (typeof agents !== 'object' || agents === null || Array.isArray(agents)) {
|
|
352
|
+
throw new InvalidAgentConfigError('agents must be an object of agentAsTool tools per role, such as { executor: [committer] }');
|
|
353
|
+
}
|
|
354
|
+
const byRole = { planner: [], executor: [], reviewer: [] };
|
|
355
|
+
for (const [role, tools] of Object.entries(agents)) {
|
|
356
|
+
if (!Object.hasOwn(byRole, role)) {
|
|
357
|
+
throw new InvalidAgentConfigError(`agents names "${role}", which is not a role; expected one of: ${Object.keys(byRole).join(', ')}`);
|
|
358
|
+
}
|
|
359
|
+
if (tools === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
if (!Array.isArray(tools)) {
|
|
362
|
+
throw new InvalidAgentConfigError(`agents.${role} must be a list of agentAsTool tools`);
|
|
363
|
+
}
|
|
364
|
+
// A delegation tool is a harness tool under its public name.
|
|
365
|
+
byRole[role] = tools;
|
|
366
|
+
}
|
|
367
|
+
return byRole;
|
|
368
|
+
}
|
|
288
369
|
/** What an explorer finds and reads with: no edits, no changes, no commands. */
|
|
289
370
|
function explorerTools({ read, policy }) {
|
|
290
371
|
const finding = createWorkspaceTools(read).filter(tool => tool.name !== 'readFile' && tool.name !== 'readFiles');
|
|
@@ -355,22 +436,30 @@ const repositoryGuidance = {
|
|
|
355
436
|
* @throws {UnrecoverableError} when a role cannot be set up, for example because
|
|
356
437
|
* `OPENAI_API_KEY` is missing, or when no program can read the repository.
|
|
357
438
|
*/
|
|
358
|
-
export async function createLLMOrchestrator({ provider, autoApprove, systemPrompts = {}, workspace = { roots: [{ path: process.cwd(), access: 'read' }] }, maxSteps, compactAt, commands, formatter, summarizeUndone = true, logger = new Logger(), onTranscript, removeWorktree = false, } = {}) {
|
|
439
|
+
export async function createLLMOrchestrator({ provider, autoApprove, systemPrompts = {}, workspace = { roots: [{ path: process.cwd(), access: 'read' }] }, maxSteps, compactAt, compaction: compactionMode, commands, formatter, summarizeUndone = true, logger = new Logger(), onTranscript, removeWorktree = false, agents = {}, } = {}) {
|
|
359
440
|
const roles = orchestratorRolesFor(provider);
|
|
441
|
+
const agentsOf = roleAgents(agents);
|
|
360
442
|
const steps = perRole('maxSteps', maxSteps, defaultRoleSteps, {
|
|
361
443
|
unit: 'steps',
|
|
362
444
|
example: '{ "executor": 120 }',
|
|
363
445
|
});
|
|
364
|
-
const compaction =
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
446
|
+
const compaction = compactAt === false
|
|
447
|
+
? { planner: false, executor: false, reviewer: false }
|
|
448
|
+
: perRole('compactAt', compactAt, defaultRoleCompaction, {
|
|
449
|
+
unit: 'tokens',
|
|
450
|
+
example: '{ "executor": 200000 }',
|
|
451
|
+
});
|
|
368
452
|
if (formatter !== undefined)
|
|
369
453
|
validateFormatter(formatter);
|
|
370
454
|
const opened = await openWorkspace(workspace, logger, formatter ? { formatter } : {});
|
|
371
455
|
const guidance = workspaceGuidance(workspace.roots);
|
|
372
456
|
const summarizer = summarizeUndone ? summarizerFor(roles.planner.provider, logger) : undefined;
|
|
373
|
-
const shared = {
|
|
457
|
+
const shared = {
|
|
458
|
+
history: opened.store,
|
|
459
|
+
...(summarizer ? { summarizer } : {}),
|
|
460
|
+
compaction: compactionMode,
|
|
461
|
+
logger,
|
|
462
|
+
};
|
|
374
463
|
// A caller's own prompt replaces our instructions and guidance, not the workspace: the model
|
|
375
464
|
// needs the roots to name a file, as `createLLMAgent` gives them.
|
|
376
465
|
const prompt = (role, own, instructions) => own === undefined
|
|
@@ -388,6 +477,7 @@ export async function createLLMOrchestrator({ provider, autoApprove, systemPromp
|
|
|
388
477
|
systemPrompt: withGuidance(explorerInstructions, guidance),
|
|
389
478
|
tools: explorerToolset,
|
|
390
479
|
maxSteps: explorerSteps,
|
|
480
|
+
compaction: compactionMode,
|
|
391
481
|
logger,
|
|
392
482
|
onTranscript: transcriptsOf('explorer'),
|
|
393
483
|
});
|
|
@@ -395,10 +485,15 @@ export async function createLLMOrchestrator({ provider, autoApprove, systemPromp
|
|
|
395
485
|
model,
|
|
396
486
|
reasoningEffort,
|
|
397
487
|
systemPrompt,
|
|
398
|
-
tools:
|
|
488
|
+
tools: [
|
|
489
|
+
...(await readingTools(opened, role, commands, explorer(provider))),
|
|
490
|
+
...agentsOf[role],
|
|
491
|
+
],
|
|
399
492
|
maxSteps: steps[role],
|
|
400
493
|
compactAt: compaction[role],
|
|
401
494
|
onTranscript: transcriptsOf(role),
|
|
495
|
+
// It only reads, so the agents it calls only read too.
|
|
496
|
+
writeRoots: [],
|
|
402
497
|
...shared,
|
|
403
498
|
});
|
|
404
499
|
const { executor } = roles;
|
|
@@ -412,11 +507,12 @@ export async function createLLMOrchestrator({ provider, autoApprove, systemPromp
|
|
|
412
507
|
model: executor.model,
|
|
413
508
|
reasoningEffort: executor.reasoningEffort,
|
|
414
509
|
systemPrompt: prompt('executor', systemPrompts.executor, executorRuns ? executorInstructions : executorInstructionsWithoutCommands),
|
|
415
|
-
tools: executorToolset,
|
|
510
|
+
tools: [...executorToolset, ...agentsOf.executor],
|
|
416
511
|
maxSteps: steps.executor,
|
|
417
512
|
compactAt: compaction.executor,
|
|
418
513
|
autoApprove,
|
|
419
514
|
onTranscript: transcriptsOf('executor'),
|
|
515
|
+
writeRoots: writeRootsOf(workspace.roots),
|
|
420
516
|
...shared,
|
|
421
517
|
}),
|
|
422
518
|
reviewerAgent: await reader(roles.reviewer, 'reviewer', prompt('reviewer', systemPrompts.reviewer, executorRuns ? reviewerInstructions : reviewerInstructionsWithoutCommands)),
|