@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.
Files changed (69) hide show
  1. package/README.md +48 -10
  2. package/dist/agent/domain/agent.d.ts +17 -0
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/runContext.d.ts +5 -0
  5. package/dist/agent/domain/runContext.js.map +1 -1
  6. package/dist/engines/domain/model/compactor.d.ts +40 -0
  7. package/dist/engines/domain/model/compactor.js +5 -0
  8. package/dist/engines/domain/model/compactor.js.map +1 -0
  9. package/dist/engines/domain/model/llmAgent.d.ts +48 -2
  10. package/dist/engines/domain/model/llmAgent.js +351 -14
  11. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  12. package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
  13. package/dist/engines/domain/model/recentCompaction.js +119 -0
  14. package/dist/engines/domain/model/recentCompaction.js.map +1 -0
  15. package/dist/engines/domain/model/stepContext.js +3 -0
  16. package/dist/engines/domain/model/stepContext.js.map +1 -1
  17. package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
  18. package/dist/engines/domain/model/triagedCompaction.js +60 -0
  19. package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
  20. package/dist/factory/infrastructure/agentLLMFactory.d.ts +40 -12
  21. package/dist/factory/infrastructure/agentLLMFactory.js +114 -18
  22. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/llm/domain/contextTriage.d.ts +26 -0
  26. package/dist/llm/domain/contextTriage.js +3 -0
  27. package/dist/llm/domain/contextTriage.js.map +1 -0
  28. package/dist/llm/domain/llm.d.ts +18 -1
  29. package/dist/llm/domain/llm.js.map +1 -1
  30. package/dist/llm/domain/message.d.ts +18 -1
  31. package/dist/llm/domain/message.js +20 -0
  32. package/dist/llm/domain/message.js.map +1 -1
  33. package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
  34. package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
  35. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  36. package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
  37. package/dist/llm/infrastructure/jevContextTriage.js +92 -0
  38. package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
  39. package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
  40. package/dist/llm/infrastructure/openAILLMClient.js +20 -9
  41. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  42. package/dist/tools/domain/preparedCall.d.ts +2 -1
  43. package/dist/tools/domain/preparedCall.js +7 -1
  44. package/dist/tools/domain/preparedCall.js.map +1 -1
  45. package/dist/tools/domain/readRegistry.d.ts +11 -0
  46. package/dist/tools/domain/readRegistry.js.map +1 -1
  47. package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
  48. package/dist/tools/infrastructure/agentAsTool.js +9 -0
  49. package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
  50. package/dist/tools/infrastructure/commandTool.js +4 -1
  51. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  52. package/dist/tools/infrastructure/exploreTool.js +2 -0
  53. package/dist/tools/infrastructure/exploreTool.js.map +1 -1
  54. package/dist/tools/infrastructure/fileTools.js +1 -0
  55. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  56. package/dist/tools/infrastructure/removeWorktreeTool.js +7 -1
  57. package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -1
  58. package/dist/tools/infrastructure/subagents.d.ts +3 -1
  59. package/dist/tools/infrastructure/subagents.js +2 -1
  60. package/dist/tools/infrastructure/subagents.js.map +1 -1
  61. package/dist/tools/infrastructure/trackedEdits.js +1 -0
  62. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  63. package/dist/tools/infrastructure/workspaceWrites.d.ts +13 -1
  64. package/dist/tools/infrastructure/workspaceWrites.js +18 -1
  65. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  66. package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
  67. package/dist/tools/infrastructure/writeCeiling.js +49 -0
  68. package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
  69. 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":"AAEA,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAOjE;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAAkB;IAClD,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC;IAC5E,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IACnD,MAAM,KAAK,GAAG,IAAI,GAAG,CACpB,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CACxB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7F,CACD,CAAC;IACF,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAC/B,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE;QAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,KAAK,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC7E,OAAO,IAAI,CAAC,IAAI,KAAK,YAAY,IAAI,IAAI;YACxC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;YACpF,CAAC,CAAC,EAAE,CAAC;IACP,CAAC,CAAC,CACF,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU,CACzB,OAAkB,EAClB,MAAe,EACf,IAAyB;IAEzB,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC;IAC5E,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,MAAM,MAAM,GAAG,CAAC,IAAiB,EAAE,EAAE,CACpC,CAAC,IAAI,CAAC,IAAI,KAAK,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC/C,CAAC,IAAI,CAAC,IAAI,KAAK,YAAY,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IACvD,MAAM,YAAY,GAAc,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7D,MAAM,UAAU,GAAc,EAAE,CAAC;IACjC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE;QAC9D,IAAI,KAAK,KAAK,KAAK,EAAE,CAAC;YACrB,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC1B,OAAO;QACR,CAAC;QACD,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACjD,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3D,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC;YAAE,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QACxF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7E,CAAC,CAAC,CAAC;IACH,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,WAAW,CAAC;QAAE,OAAO,SAAS,CAAC;IAChF,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,wBAAwB,CAAC,CAAC,GAAG,YAAY,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;AAClG,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,WAAW,CAAC,IAAuB;IAClD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,GAAG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;IAC9F,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;QACxB,CAAC,CAAC,8EAA8E;QAChF,CAAC,CAAC,yHAAyH,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;AACjJ,CAAC;AAED,SAAS,OAAO,CAAC,IAAY,EAAE,GAAW;IACzC,OAAO,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC;AAC7D,CAAC","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 input tokens at which the provider compacts the conversation on its side: it replaces
59
- * every turn before that call with its own summary, which only its model reads back. The
60
- * conversation keeps every original turn. Without it, the conversation is never compacted.
61
- * At least 50,000 on Anthropic.
62
- */
63
- compactAt?: number;
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 input tokens at which each role's provider compacts its conversation, as
98
- * `CreateLLMAgentOptions.compactAt`: one number for every role, or a number per role. Without
99
- * it, no role compacts.
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
- // No role compacts unless asked to.
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, compactAt, systemPrompt, logger, }) {
131
+ function llmClientFor(provider, { model, reasoningEffort, systemPrompt, logger, }) {
83
132
  switch (provider) {
84
133
  case 'anthropic':
85
134
  return new ClaudeLLMClient({
86
- model: model ?? defaultLLMAgentModels.anthropic,
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: model ?? defaultLLMAgentModels.openai,
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 = perRole('compactAt', compactAt, defaultRoleCompaction, {
365
- unit: 'tokens',
366
- example: '{ "executor": 200000 }',
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 = { history: opened.store, ...(summarizer ? { summarizer } : {}), logger };
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: await readingTools(opened, role, commands, explorer(provider)),
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)),