@mikode13/harness 11.0.1 → 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 +186 -16
- package/dist/agent/domain/agent.d.ts +41 -3
- package/dist/agent/domain/agent.js.map +1 -1
- package/dist/agent/domain/approval.d.ts +20 -0
- package/dist/agent/domain/approval.js.map +1 -1
- package/dist/agent/domain/inbox.d.ts +42 -0
- package/dist/agent/domain/inbox.js +81 -0
- package/dist/agent/domain/inbox.js.map +1 -0
- package/dist/agent/domain/oneRunAtATime.d.ts +17 -0
- package/dist/agent/domain/oneRunAtATime.js +32 -0
- package/dist/agent/domain/oneRunAtATime.js.map +1 -0
- package/dist/agent/domain/runContext.d.ts +9 -0
- package/dist/agent/domain/runContext.js +5 -0
- package/dist/agent/domain/runContext.js.map +1 -1
- package/dist/agent/domain/session.d.ts +15 -0
- package/dist/agent/domain/session.js +80 -0
- package/dist/agent/domain/session.js.map +1 -0
- package/dist/engines/claude/infrastructure/model/claudeAgent.d.ts +6 -2
- package/dist/engines/claude/infrastructure/model/claudeAgent.js +20 -5
- package/dist/engines/claude/infrastructure/model/claudeAgent.js.map +1 -1
- package/dist/engines/codex/infrastructure/model/codexAgent.d.ts +5 -1
- package/dist/engines/codex/infrastructure/model/codexAgent.js +13 -2
- package/dist/engines/codex/infrastructure/model/codexAgent.js.map +1 -1
- package/dist/engines/domain/model/backgroundAgents.d.ts +5 -2
- package/dist/engines/domain/model/backgroundAgents.js +11 -3
- package/dist/engines/domain/model/backgroundAgents.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 +53 -2
- package/dist/engines/domain/model/llmAgent.js +419 -24
- 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 +56 -15
- package/dist/factory/infrastructure/agentLLMFactory.js +144 -25
- package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -0
- 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/orchestration/domain/model/orchestratorAgent.d.ts +9 -1
- package/dist/orchestration/domain/model/orchestratorAgent.js +20 -3
- package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
- package/dist/recovery/domain/history.d.ts +10 -3
- package/dist/recovery/domain/history.js +1 -0
- package/dist/recovery/domain/history.js.map +1 -1
- package/dist/recovery/domain/recoveryStore.d.ts +14 -2
- package/dist/recovery/domain/recoveryStore.js.map +1 -1
- package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +21 -2
- package/dist/recovery/infrastructure/fileRecoveryStore.js +58 -6
- package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
- package/dist/recovery/infrastructure/folderClaims.d.ts +27 -0
- package/dist/recovery/infrastructure/folderClaims.js +124 -0
- package/dist/recovery/infrastructure/folderClaims.js.map +1 -0
- package/dist/shared/domain/session.d.ts +12 -0
- package/dist/shared/domain/session.js +2 -0
- package/dist/shared/domain/session.js.map +1 -0
- package/dist/tools/domain/preparedCall.d.ts +5 -2
- 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 +6 -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.d.ts +2 -0
- package/dist/tools/infrastructure/fileTools.js +10 -1
- package/dist/tools/infrastructure/fileTools.js.map +1 -1
- package/dist/tools/infrastructure/removeWorktreeTool.d.ts +23 -0
- package/dist/tools/infrastructure/removeWorktreeTool.js +178 -0
- package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -0
- 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 +17 -1
- package/dist/tools/infrastructure/workspaceWrites.js +48 -17
- package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
- package/dist/tools/infrastructure/worktrees.d.ts +49 -0
- package/dist/tools/infrastructure/worktrees.js +154 -0
- package/dist/tools/infrastructure/worktrees.js.map +1 -0
- 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/dist/tools/infrastructure/writeSession.d.ts +5 -1
- package/dist/tools/infrastructure/writeSession.js +7 -3
- package/dist/tools/infrastructure/writeSession.js.map +1 -1
- package/dist/workflow/domain/workflow.d.ts +3 -0
- package/dist/workflow/domain/workflow.js.map +1 -1
- package/dist/workflow/domain/workflowRunner.js +8 -2
- package/dist/workflow/domain/workflowRunner.js.map +1 -1
- package/package.json +2 -1
|
@@ -4,8 +4,14 @@ import { delegationOf, RunContext, runContextOf, withRunContext, } from '#src/ag
|
|
|
4
4
|
import { InvalidAgentConfigError, RecoverableError, UnrecoverableError, withSpentTokens, } from '#src/shared/domain/errors';
|
|
5
5
|
import { classifyHostFailure, classifyLocalFailure, classifyProviderFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
|
|
6
6
|
import { Conversation } from '#src/llm/domain/conversation';
|
|
7
|
+
import { keptScore } from '#src/llm/domain/contextTriage';
|
|
8
|
+
import { MaxContextError } from '#src/llm/domain/errors';
|
|
9
|
+
import { compactedContext, } from '#src/llm/domain/message';
|
|
7
10
|
import { asNative, nativeOf } from '#src/llm/domain/tool';
|
|
8
11
|
import { readAnswers } from '#src/agent/domain/question';
|
|
12
|
+
import { readerOf } from '#src/agent/domain/inbox';
|
|
13
|
+
import { OneRunAtATime } from '#src/agent/domain/oneRunAtATime';
|
|
14
|
+
import { newSession, sessionOf } from '#src/agent/domain/session';
|
|
9
15
|
import { isDelegating, prepareCall, RunEndingToolError, ToolExecutionError, } from '#src/tools/domain/preparedCall';
|
|
10
16
|
import { ReadRegistry } from '#src/tools/domain/readRegistry';
|
|
11
17
|
import { InvalidToolInputError } from '#src/tools/domain/tool';
|
|
@@ -13,6 +19,9 @@ import { isAbortError } from '#src/shared/domain/isAbortError';
|
|
|
13
19
|
import { addTokens } from '#src/shared/domain/tokens';
|
|
14
20
|
import { anotherAttempt, askAt, CommandLoops, extensionNote, loopQuestion, loopStop, loopWarning, repeatedAnswersAt, repeatNote, repeatQuestion, repeatStop, stop, warnAt, } from "./commandLoops.js";
|
|
15
21
|
import { BackgroundAgents, sentNote } from "./backgroundAgents.js";
|
|
22
|
+
import { compactionOff } from "./compactor.js";
|
|
23
|
+
import { messagesAsText, recentCut, recentlyKept, summaryRequest, transcriptSummaryRequest, } from "./recentCompaction.js";
|
|
24
|
+
import { triageCandidates, triagedCut, triagedKept } from "./triagedCompaction.js";
|
|
16
25
|
import { measureContext } from "./stepContext.js";
|
|
17
26
|
import { askUserDefinition, askUserName, describeAnswers, readQuestions, summarizeQuestions, } from "./askUser.js";
|
|
18
27
|
import { followHistory, transcriptOf, undoNote, } from "./historyFollower.js";
|
|
@@ -38,8 +47,25 @@ function describePart(part) {
|
|
|
38
47
|
case 'compaction':
|
|
39
48
|
// The provider's, for its client to send back; the step's context shows its effect.
|
|
40
49
|
return undefined;
|
|
50
|
+
case 'contextSummary':
|
|
51
|
+
// Narrated as a `compaction` event when it is written.
|
|
52
|
+
return undefined;
|
|
41
53
|
}
|
|
42
54
|
}
|
|
55
|
+
/** The tokens of context a call sent: all its input, cached or not. */
|
|
56
|
+
function contextSize(tokens) {
|
|
57
|
+
return tokens.inputTokens + tokens.readCacheTokens + tokens.writtenCacheTokens;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `message` with each summary in it reduced to its text: what a summary kept is in the
|
|
61
|
+
* conversation before it already.
|
|
62
|
+
*/
|
|
63
|
+
function withoutKeptCopies(message) {
|
|
64
|
+
return {
|
|
65
|
+
role: message.role,
|
|
66
|
+
content: message.content.map(part => part.type === 'contextSummary' ? { type: part.type, summary: part.summary } : part),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
43
69
|
function narrate(part, callback, summary, exitCode) {
|
|
44
70
|
const event = describePart(part);
|
|
45
71
|
if (!event)
|
|
@@ -117,6 +143,16 @@ function withNote(message, note) {
|
|
|
117
143
|
content: [...message.content.slice(0, -1), { ...last, output: `${last.output}\n\n${note}` }],
|
|
118
144
|
};
|
|
119
145
|
}
|
|
146
|
+
// What the model is told of a message from its inbox, ahead of the message itself.
|
|
147
|
+
const sentByUser = 'The user sent this while you worked:';
|
|
148
|
+
/** What the user sent since the run last looked, and the message the model reads it in. */
|
|
149
|
+
function takeInbox(inbox) {
|
|
150
|
+
const texts = inbox?.take() ?? [];
|
|
151
|
+
if (texts.length === 0)
|
|
152
|
+
return undefined;
|
|
153
|
+
const content = [sentByUser, ...texts].map(text => ({ type: 'text', text }));
|
|
154
|
+
return { texts, message: { role: 'user', content } };
|
|
155
|
+
}
|
|
120
156
|
/**
|
|
121
157
|
* An agent whose conversation is owned by MiKode: every run sends the whole context to a
|
|
122
158
|
* stateless `LLMClient`. Within a run the model may call the agent's tools; the agent runs them
|
|
@@ -138,14 +174,34 @@ export class LLMAgent {
|
|
|
138
174
|
history;
|
|
139
175
|
summarizer;
|
|
140
176
|
onTranscript;
|
|
177
|
+
writeRoots;
|
|
178
|
+
compactor;
|
|
179
|
+
// The tokens of context the model was last sent in a run that was kept, as its provider
|
|
180
|
+
// reported them, which the recent compactor measures against its threshold: the
|
|
181
|
+
// conversation's size, not a run's count.
|
|
182
|
+
contextTokens;
|
|
141
183
|
// The latest move through the history this conversation has followed.
|
|
142
184
|
seenRevision = 0;
|
|
143
185
|
// What this conversation has seen of each file, so an edit starts from a version it read.
|
|
144
186
|
reads = new ReadRegistry();
|
|
145
|
-
|
|
187
|
+
runs = new OneRunAtATime();
|
|
188
|
+
// The session its runs belong to when their options name none.
|
|
189
|
+
session = newSession();
|
|
190
|
+
constructor({ llmClient, messages, tools = [], maxSteps = 65, autoApprove = false, logger, history, summarizer, onTranscript, writeRoots, compactor = compactionOff, }) {
|
|
146
191
|
if (!Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
147
192
|
throw new InvalidAgentConfigError(`maxSteps must be a positive integer; got ${String(maxSteps)}`);
|
|
148
193
|
}
|
|
194
|
+
if (compactor.by === 'harness') {
|
|
195
|
+
if (compactor.at !== undefined && !(Number.isInteger(compactor.at) && compactor.at > 0)) {
|
|
196
|
+
throw new InvalidAgentConfigError(`compactAt must be a positive whole number of tokens, not ${String(compactor.at)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (compactor.at !== undefined) {
|
|
200
|
+
if (!llmClient.checkCompactAt) {
|
|
201
|
+
throw new InvalidAgentConfigError('This model client cannot compact; leave compactAt out');
|
|
202
|
+
}
|
|
203
|
+
llmClient.checkCompactAt(compactor.at);
|
|
204
|
+
}
|
|
149
205
|
this.llmClient = llmClient;
|
|
150
206
|
this.conversation = new Conversation(messages);
|
|
151
207
|
this.maxSteps = maxSteps;
|
|
@@ -154,6 +210,8 @@ export class LLMAgent {
|
|
|
154
210
|
this.history = history;
|
|
155
211
|
this.summarizer = summarizer;
|
|
156
212
|
this.onTranscript = onTranscript;
|
|
213
|
+
this.writeRoots = writeRoots;
|
|
214
|
+
this.compactor = compactor;
|
|
157
215
|
this.tools = new Map(tools.map(tool => [tool.name, tool]));
|
|
158
216
|
if (this.tools.size !== tools.length) {
|
|
159
217
|
// The model calls tools by name, so a second one with the same name could never run.
|
|
@@ -227,12 +285,206 @@ export class LLMAgent {
|
|
|
227
285
|
this.logger?.warn(`The summary of the undone turns failed: ${why}`);
|
|
228
286
|
}, classifyHostFailure, 'LLM agent logger failed');
|
|
229
287
|
}
|
|
230
|
-
|
|
288
|
+
warnContextOutgrown() {
|
|
289
|
+
treatErrors(() => {
|
|
290
|
+
this.logger?.warn('The context no longer fits the model; the next call compacts it and tries again');
|
|
291
|
+
}, classifyHostFailure, 'LLM agent logger failed');
|
|
292
|
+
}
|
|
293
|
+
warnTriageFailed(why) {
|
|
294
|
+
treatErrors(() => {
|
|
295
|
+
this.logger?.warn(`Jev could not choose what to keep, so the recent compactor did: ${why}`);
|
|
296
|
+
}, classifyHostFailure, 'LLM agent logger failed');
|
|
297
|
+
}
|
|
298
|
+
warnCompactionFailed(why) {
|
|
299
|
+
treatErrors(() => {
|
|
300
|
+
this.logger?.warn(`The context could not be compacted: ${why}`);
|
|
301
|
+
}, classifyHostFailure, 'LLM agent logger failed');
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Warns that a summary the model was called for failed. A logger that throws ends the run,
|
|
305
|
+
* carrying what that call spent, which the run has not counted yet.
|
|
306
|
+
*/
|
|
307
|
+
warnSummaryCallFailed(why, { usage, unreported }) {
|
|
308
|
+
try {
|
|
309
|
+
this.warnCompactionFailed(why);
|
|
310
|
+
}
|
|
311
|
+
catch (hostFailure) {
|
|
312
|
+
throw withSpentTokens(hostFailure, usage, unreported);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Has the agent's own model summarize the context before the harness compactor's cut, and
|
|
317
|
+
* records the summary in the run, with what it keeps word for word. With a triage, Jev chooses
|
|
318
|
+
* what is kept; when it fails, which it logs, the recent cut does. `compacted` is false when
|
|
319
|
+
* the model had answered too few times, which costs no call, when Jev kept every answer
|
|
320
|
+
* whole, unless the context no longer fits, or when the summary failed as `summarize` says.
|
|
321
|
+
*/
|
|
322
|
+
async compactHarness(compactor, runMessages, after, options) {
|
|
323
|
+
// The run's messages are not copied, so its prompt is found where it is.
|
|
324
|
+
const context = compactedContext([...this.conversation.contextAfter(after), ...runMessages]);
|
|
325
|
+
const [prompt] = runMessages;
|
|
326
|
+
let chosen = prompt && (await this.harnessCut(compactor, context, prompt, options.signal));
|
|
327
|
+
// A context that no longer fits must lose something: when Jev's cut keeps it all, even the
|
|
328
|
+
// last answer it always keeps, recent cuts.
|
|
329
|
+
if (prompt && compactor.triage && !chosen?.cut && options.outgrown) {
|
|
330
|
+
chosen = this.recentChoice(compactor, context, prompt);
|
|
331
|
+
}
|
|
332
|
+
if (!chosen?.cut) {
|
|
333
|
+
const why = chosen?.triaged
|
|
334
|
+
? 'Jev kept every answer whole, so there is nothing to summarize'
|
|
335
|
+
: 'the model has not answered yet, so there is nothing to summarize';
|
|
336
|
+
// A context that no longer fits ends the run, with why in its error rather than the log.
|
|
337
|
+
if (!options.outgrown)
|
|
338
|
+
this.warnCompactionFailed(why);
|
|
339
|
+
// Jev would keep the same again: it is not asked again in the same run.
|
|
340
|
+
return {
|
|
341
|
+
compacted: false,
|
|
342
|
+
called: chosen?.triaged ?? false,
|
|
343
|
+
usage: undefined,
|
|
344
|
+
unreported: false,
|
|
345
|
+
why,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// The run's context no longer fitting is what makes a failure end it.
|
|
349
|
+
return this.summarize(chosen.cut, chosen.kept, runMessages, {
|
|
350
|
+
...options,
|
|
351
|
+
context,
|
|
352
|
+
endsRun: options.outgrown,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Where the harness compactor cuts `context`, and what it keeps, as the summary request says
|
|
357
|
+
* it. `triaged` when Jev was asked and answered.
|
|
358
|
+
*/
|
|
359
|
+
async harnessCut(compactor, context, prompt, signal) {
|
|
360
|
+
const { triage } = compactor;
|
|
361
|
+
const candidates = triage ? triageCandidates(context) : [];
|
|
362
|
+
let scores = triage && candidates.length === 0 ? [] : undefined;
|
|
363
|
+
if (triage && candidates.length > 0) {
|
|
364
|
+
const latest = context.findLast(message => message.role === 'assistant');
|
|
365
|
+
try {
|
|
366
|
+
scores = await triage.score({
|
|
367
|
+
task: prompt.content
|
|
368
|
+
.flatMap(part => (part.type === 'text' ? [part.text] : []))
|
|
369
|
+
.join('\n'),
|
|
370
|
+
latestAnswer: latest ? messagesAsText([latest], 8_000, 3_000) : '',
|
|
371
|
+
results: candidates.map(({ tool, input, output }) => ({ tool, input, output })),
|
|
372
|
+
}, signal);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (isAbortError(error))
|
|
376
|
+
throw error;
|
|
377
|
+
signal.throwIfAborted();
|
|
378
|
+
this.warnTriageFailed(describeFailure(error));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (scores) {
|
|
382
|
+
const kept = candidates.filter((_, index) => (scores[index] ?? 0) >= keptScore);
|
|
383
|
+
return {
|
|
384
|
+
cut: triagedCut(context, prompt, new Set(kept.map(({ callId }) => callId))),
|
|
385
|
+
kept: triagedKept(kept),
|
|
386
|
+
triaged: candidates.length > 0,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return this.recentChoice(compactor, context, prompt);
|
|
390
|
+
}
|
|
391
|
+
recentChoice(compactor, context, prompt) {
|
|
392
|
+
return {
|
|
393
|
+
cut: recentCut(context, prompt, compactor.keptSteps),
|
|
394
|
+
kept: recentlyKept(compactor.keptSteps),
|
|
395
|
+
triaged: false,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Has the agent's own model summarize what `cut` does not keep, and records the summary in the
|
|
400
|
+
* run. Once the threshold passed, the model reads `context`, the context as it was last sent,
|
|
401
|
+
* from its provider's cache; once the context no longer fit, it reads what it summarizes as
|
|
402
|
+
* text. `compacted` is false when the model wrote no summary or the provider failed in
|
|
403
|
+
* passing, which it logs; any other failure is thrown. The summary's usage is the run's either
|
|
404
|
+
* way.
|
|
405
|
+
*
|
|
406
|
+
* @param kept what `cut` keeps, as the summary request tells the model.
|
|
407
|
+
* @param endsRun whether a failure ends the run, which throws why instead of logging it: the
|
|
408
|
+
* run's context no longer fits. `outgrown` alone may be this call's own fallback.
|
|
409
|
+
*/
|
|
410
|
+
async summarize(cut, kept, runMessages, { context, signal, tools, outgrown, endsRun, }) {
|
|
411
|
+
const request = outgrown
|
|
412
|
+
? { context: [transcriptSummaryRequest(cut.summarized, kept)], tools: [] }
|
|
413
|
+
: { context: [...structuredClone(context), summaryRequest(kept)], tools };
|
|
414
|
+
let response;
|
|
415
|
+
try {
|
|
416
|
+
response = await this.llmClient.send(request, signal);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
if (isAbortError(error))
|
|
420
|
+
throw error;
|
|
421
|
+
const failure = classifyProviderFailure(error, 'The context summary failed');
|
|
422
|
+
if (failure instanceof MaxContextError && !outgrown) {
|
|
423
|
+
// The context grew past the model since it was last sent: the model reads what it
|
|
424
|
+
// summarizes as text instead, and this call is the run's too.
|
|
425
|
+
let fallback;
|
|
426
|
+
try {
|
|
427
|
+
fallback = await this.summarize(cut, kept, runMessages, {
|
|
428
|
+
context,
|
|
429
|
+
signal,
|
|
430
|
+
tools,
|
|
431
|
+
outgrown: true,
|
|
432
|
+
endsRun,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
catch (fallbackFailure) {
|
|
436
|
+
throw withSpentTokens(fallbackFailure, failure.tokens, failure.usageUnreported);
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
...fallback,
|
|
440
|
+
usage: addTokens(failure.tokens, fallback.usage),
|
|
441
|
+
unreported: fallback.unreported || failure.usageUnreported,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
// Only a passing failure of the provider goes without a summary: anything else, such as
|
|
445
|
+
// a logger or a response the client could not map, ends the run as any call's would.
|
|
446
|
+
if (!(failure instanceof RecoverableError))
|
|
447
|
+
throw failure;
|
|
448
|
+
const failed = {
|
|
449
|
+
compacted: false,
|
|
450
|
+
called: true,
|
|
451
|
+
usage: failure.tokens,
|
|
452
|
+
unreported: failure.usageUnreported,
|
|
453
|
+
why: describeFailure(failure),
|
|
454
|
+
};
|
|
455
|
+
if (!endsRun)
|
|
456
|
+
this.warnSummaryCallFailed(failed.why, failed);
|
|
457
|
+
return failed;
|
|
458
|
+
}
|
|
459
|
+
const spent = { called: true, usage: response.usage, unreported: !response.usage };
|
|
460
|
+
const summary = response.message.content
|
|
461
|
+
.flatMap(part => (part.type === 'text' ? [part.text] : []))
|
|
462
|
+
.join('\n')
|
|
463
|
+
.trim();
|
|
464
|
+
// A cut or refused answer, or one that calls a tool, is no summary of the work.
|
|
465
|
+
if (response.stopReason !== 'completed' ||
|
|
466
|
+
response.message.content.some(part => part.type === 'toolCall') ||
|
|
467
|
+
summary === '') {
|
|
468
|
+
const why = 'the model did not answer with a summary';
|
|
469
|
+
if (!endsRun)
|
|
470
|
+
this.warnSummaryCallFailed(why, spent);
|
|
471
|
+
return { compacted: false, ...spent, why };
|
|
472
|
+
}
|
|
473
|
+
runMessages.push({
|
|
474
|
+
role: 'user',
|
|
475
|
+
content: [{ type: 'contextSummary', summary, kept: cut.kept }],
|
|
476
|
+
});
|
|
477
|
+
return { compacted: true, ...spent };
|
|
478
|
+
}
|
|
479
|
+
async llmCall(runMessages, after, { signal, step, callback, tools, compactAt, }) {
|
|
231
480
|
// Sent as a copy, so what the run records is what the agent built, whatever the client does.
|
|
232
|
-
const context = [
|
|
481
|
+
const context = compactedContext([
|
|
482
|
+
...this.conversation.contextAfter(after),
|
|
483
|
+
...structuredClone(runMessages),
|
|
484
|
+
]);
|
|
233
485
|
let response;
|
|
234
486
|
try {
|
|
235
|
-
response = await this.llmClient.send({ context, tools }, signal);
|
|
487
|
+
response = await this.llmClient.send({ context, tools, compactAt }, signal);
|
|
236
488
|
}
|
|
237
489
|
catch (error) {
|
|
238
490
|
const failure = classifyProviderFailure(error, 'The LLM call failed');
|
|
@@ -276,6 +528,7 @@ export class LLMAgent {
|
|
|
276
528
|
if (this.autoApprove || risk !== 'destructive')
|
|
277
529
|
return undefined;
|
|
278
530
|
const command = 'command' in prepared ? prepared.command : undefined;
|
|
531
|
+
const worktree = 'worktree' in prepared ? prepared.worktree : undefined;
|
|
279
532
|
const retry = 'Do not retry the same call; choose another way, or ask the user.';
|
|
280
533
|
if (!approve) {
|
|
281
534
|
return `The call to "${call.name}" was denied: it is destructive, and no one was asked to allow it. ${retry}`;
|
|
@@ -292,6 +545,7 @@ export class LLMAgent {
|
|
|
292
545
|
risk,
|
|
293
546
|
// A copy too: the approver must not be able to change what runs.
|
|
294
547
|
...(command ? { command: structuredClone(command) } : {}),
|
|
548
|
+
...(worktree ? { worktree: structuredClone(worktree) } : {}),
|
|
295
549
|
};
|
|
296
550
|
decision = readDecision(await approve(request, signal));
|
|
297
551
|
}
|
|
@@ -610,12 +864,20 @@ export class LLMAgent {
|
|
|
610
864
|
* Runs inside the context of the run that reached it, or in one of its own that it ends with
|
|
611
865
|
* the run: completed, failed or cancelled. Only a run that owns its context names the history
|
|
612
866
|
* run it recorded, since the run is over only when that context ends.
|
|
867
|
+
*
|
|
868
|
+
* @throws {RunInProgressError} when another run on this agent is still working.
|
|
613
869
|
*/
|
|
614
|
-
|
|
870
|
+
run(prompt, options) {
|
|
871
|
+
return this.runs.run(() => this.runAlone(prompt, options));
|
|
872
|
+
}
|
|
873
|
+
async runAlone(prompt, options) {
|
|
874
|
+
const session = sessionOf(options) ?? this.session;
|
|
875
|
+
// Returned as copies: a consumer changing one must not change what later runs record.
|
|
615
876
|
const inherited = runContextOf(options);
|
|
616
|
-
if (inherited)
|
|
617
|
-
return this.runSteps(prompt, options, inherited);
|
|
618
|
-
|
|
877
|
+
if (inherited) {
|
|
878
|
+
return { ...(await this.runSteps(prompt, options, inherited)), session: { ...session } };
|
|
879
|
+
}
|
|
880
|
+
const context = new RunContext(session);
|
|
619
881
|
let end = 'failed';
|
|
620
882
|
let response;
|
|
621
883
|
try {
|
|
@@ -632,7 +894,7 @@ export class LLMAgent {
|
|
|
632
894
|
}
|
|
633
895
|
// Read once the run ended: a run that changed nothing left the history then.
|
|
634
896
|
const { runId } = context.historyRun;
|
|
635
|
-
return
|
|
897
|
+
return { ...response, ...(runId ? { runId } : {}), session: { ...session } };
|
|
636
898
|
}
|
|
637
899
|
/**
|
|
638
900
|
* What the run started ends here, but its result stands: the changes it recorded are already
|
|
@@ -675,7 +937,7 @@ export class LLMAgent {
|
|
|
675
937
|
description,
|
|
676
938
|
inputSchema,
|
|
677
939
|
})),
|
|
678
|
-
messages: seen.messages(),
|
|
940
|
+
messages: seen.messages().map(withoutKeptCopies),
|
|
679
941
|
...(outcome === 'completed' ? {} : { error: describeFailure(error) }),
|
|
680
942
|
});
|
|
681
943
|
await this.onTranscript({
|
|
@@ -700,6 +962,7 @@ export class LLMAgent {
|
|
|
700
962
|
}
|
|
701
963
|
async takeSteps(prompt, options, context, seen) {
|
|
702
964
|
const { signal, onProgress: callback = ignoreProgress } = options;
|
|
965
|
+
const delegation = delegationOf(options);
|
|
703
966
|
// A tool narrates through this without ever failing: a failing callback would reach
|
|
704
967
|
// only the tool, which turns any failure into a result for the model. The run ends on
|
|
705
968
|
// it once that tool returns.
|
|
@@ -716,6 +979,21 @@ export class LLMAgent {
|
|
|
716
979
|
};
|
|
717
980
|
// The agents the run's calls send to work in the background.
|
|
718
981
|
const background = new BackgroundAgents(signal, onProgress);
|
|
982
|
+
// Checked whatever it is, as a JavaScript consumer can pass anything.
|
|
983
|
+
const inbox = options.inbox === undefined ? undefined : readerOf(options.inbox);
|
|
984
|
+
// What the user sends while the run waits for its agents is worth the model's attention.
|
|
985
|
+
const stopListening = inbox?.onMessage(() => {
|
|
986
|
+
background.interrupt();
|
|
987
|
+
});
|
|
988
|
+
// What the run read from its inbox and has not kept yet, which goes back there if the run
|
|
989
|
+
// is not kept.
|
|
990
|
+
const unkept = [];
|
|
991
|
+
const takeSent = () => {
|
|
992
|
+
const sent = takeInbox(inbox);
|
|
993
|
+
if (sent)
|
|
994
|
+
unkept.push(...sent.texts);
|
|
995
|
+
return sent?.message;
|
|
996
|
+
};
|
|
719
997
|
const start = Date.now();
|
|
720
998
|
const runMessages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
|
|
721
999
|
let tokens;
|
|
@@ -726,15 +1004,22 @@ export class LLMAgent {
|
|
|
726
1004
|
// The turn this one follows: the last recorded, unless the workspace moved since.
|
|
727
1005
|
let after = this.conversation.currentTurn;
|
|
728
1006
|
let followed;
|
|
729
|
-
//
|
|
1007
|
+
// The context's size as this run knows it, the agent's once the run is kept: a run that
|
|
1008
|
+
// is not kept leaves the conversation, and so its size, as they were.
|
|
1009
|
+
let contextTokens = this.contextTokens;
|
|
1010
|
+
// What the model has seen so far, whichever turn the run ends up following: every message,
|
|
1011
|
+
// those a summary replaced too, for a person debugging the run.
|
|
730
1012
|
seen.messages = () => [...this.conversation.contextAfter(after), ...runMessages];
|
|
731
1013
|
try {
|
|
732
1014
|
if (this.history) {
|
|
733
1015
|
followed = await this.followHistory(this.history);
|
|
734
1016
|
after = followed.turn;
|
|
735
1017
|
// The files may no longer hold what the conversation saw of them.
|
|
736
|
-
if (followed.revisions.length > 0)
|
|
1018
|
+
if (followed.revisions.length > 0) {
|
|
737
1019
|
this.reads.clear();
|
|
1020
|
+
// Its context is no longer the one last sent.
|
|
1021
|
+
contextTokens = undefined;
|
|
1022
|
+
}
|
|
738
1023
|
if (followed.undone.length > 0) {
|
|
739
1024
|
const summary = await this.summarizeUndone(followed, signal);
|
|
740
1025
|
tokens = addTokens(tokens, summary.usage);
|
|
@@ -773,11 +1058,13 @@ export class LLMAgent {
|
|
|
773
1058
|
// Recorded only once the run ends in a way that keeps it, so it never leaves a tool call
|
|
774
1059
|
// without its result; a run that is not kept gives its note on what was undone again.
|
|
775
1060
|
const remember = () => {
|
|
1061
|
+
unkept.length = 0;
|
|
776
1062
|
this.conversation.addRunAfter(after, runMessages, context.historyRun);
|
|
777
1063
|
// With the turns that read them, and only then.
|
|
778
1064
|
reads.commit();
|
|
779
1065
|
if (followed)
|
|
780
1066
|
this.seenRevision = followed.latestRevision;
|
|
1067
|
+
this.contextTokens = contextTokens;
|
|
781
1068
|
};
|
|
782
1069
|
// A run kept although it failed stops its agents first, and tells the next run what
|
|
783
1070
|
// became of each one, which would otherwise wait for answers that never come.
|
|
@@ -788,20 +1075,110 @@ export class LLMAgent {
|
|
|
788
1075
|
runMessages.push(ended);
|
|
789
1076
|
remember();
|
|
790
1077
|
};
|
|
1078
|
+
// Whether the context outgrew the model at the last call, so this one compacts it first,
|
|
1079
|
+
// and how large it was, when the provider said.
|
|
1080
|
+
let outgrown = false;
|
|
1081
|
+
let outgrownTokens;
|
|
1082
|
+
// A summary the model failed to write is not asked for again in the same run, unless the
|
|
1083
|
+
// context no longer fits.
|
|
1084
|
+
let summaryFailed = false;
|
|
1085
|
+
// A summary was just written, so the next call measures what it left.
|
|
1086
|
+
let summarized = false;
|
|
1087
|
+
const { compactor } = this;
|
|
791
1088
|
for (let step = 1; step <= this.maxSteps; step++) {
|
|
792
1089
|
// An agent at work may have narrated through a callback that failed.
|
|
793
1090
|
throwIfProgressFailed();
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1091
|
+
if (compactor.by === 'harness' &&
|
|
1092
|
+
(outgrown ||
|
|
1093
|
+
(!summaryFailed &&
|
|
1094
|
+
compactor.at !== undefined &&
|
|
1095
|
+
contextTokens !== undefined &&
|
|
1096
|
+
contextTokens >= compactor.at))) {
|
|
1097
|
+
const compaction = await this.compactHarness(compactor, runMessages, after, {
|
|
1098
|
+
signal,
|
|
1099
|
+
tools,
|
|
1100
|
+
outgrown,
|
|
1101
|
+
});
|
|
1102
|
+
tokens = addTokens(tokens, compaction.usage);
|
|
1103
|
+
unreported ||= compaction.unreported;
|
|
1104
|
+
if (compaction.compacted) {
|
|
1105
|
+
const compacted = outgrown ? outgrownTokens : contextTokens;
|
|
1106
|
+
contextTokens = undefined;
|
|
1107
|
+
summarized = true;
|
|
1108
|
+
emit({
|
|
1109
|
+
type: 'compaction',
|
|
1110
|
+
by: 'harness',
|
|
1111
|
+
reason: outgrown ? 'maxContext' : 'threshold',
|
|
1112
|
+
...(compacted === undefined ? {} : { contextTokens: compacted }),
|
|
1113
|
+
}, callback);
|
|
1114
|
+
}
|
|
1115
|
+
else if (outgrown) {
|
|
1116
|
+
throw new MaxContextError('The context no longer fits the model', {
|
|
1117
|
+
cause: `It no longer fit, and could not be compacted: ${compaction.why ?? 'it is unknown why'}.`,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
summaryFailed = compaction.called;
|
|
1122
|
+
}
|
|
1123
|
+
// An agent at work may have ended the run while the summary was written.
|
|
1124
|
+
throwIfProgressFailed();
|
|
1125
|
+
}
|
|
1126
|
+
let called;
|
|
1127
|
+
try {
|
|
1128
|
+
called = await this.llmCall(runMessages, after, {
|
|
1129
|
+
signal,
|
|
1130
|
+
step,
|
|
1131
|
+
callback,
|
|
1132
|
+
tools,
|
|
1133
|
+
// The harness's own compactor leaves the provider's off.
|
|
1134
|
+
compactAt: compactor.by === 'harness' ? undefined : outgrown ? 'now' : compactor.at,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
// Compacted once and tried again, even with compaction off: a second time in a row,
|
|
1139
|
+
// with nothing to compact it, or no step left to try ends the run.
|
|
1140
|
+
if (!(error instanceof MaxContextError) ||
|
|
1141
|
+
outgrown ||
|
|
1142
|
+
(compactor.by === 'provider' && !this.llmClient.checkCompactAt) ||
|
|
1143
|
+
step === this.maxSteps) {
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
// Billed, and already narrated as a step.
|
|
1147
|
+
tokens = addTokens(tokens, error.tokens);
|
|
1148
|
+
unreported ||= error.usageUnreported;
|
|
1149
|
+
this.warnContextOutgrown();
|
|
1150
|
+
outgrown = true;
|
|
1151
|
+
outgrownTokens = error.tokens && contextSize(error.tokens);
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const { response, context: sent } = called;
|
|
800
1155
|
tokens = addTokens(tokens, response.usage);
|
|
801
1156
|
unreported ||= !response.usage;
|
|
1157
|
+
contextTokens = response.usage && contextSize(response.usage);
|
|
1158
|
+
// What a summary keeps word for word may stay past the threshold: summarizing it again
|
|
1159
|
+
// on every step would cost a whole context each time and keep nothing less.
|
|
1160
|
+
if (summarized &&
|
|
1161
|
+
compactor.at !== undefined &&
|
|
1162
|
+
contextTokens !== undefined &&
|
|
1163
|
+
contextTokens >= compactor.at) {
|
|
1164
|
+
summaryFailed = true;
|
|
1165
|
+
this.warnCompactionFailed(`what the summary kept is still ${String(contextTokens)} tokens, past the threshold of ${String(compactor.at)}, so the context is not compacted again in this run unless it no longer fits`);
|
|
1166
|
+
}
|
|
1167
|
+
summarized = false;
|
|
802
1168
|
// After the tokens are counted, so a throwing callback still reports them, and for an
|
|
803
1169
|
// unusable answer too: the call was sent, and billed.
|
|
804
1170
|
this.narrateStep(step, response.usage, { context: sent, tools }, callback);
|
|
1171
|
+
if (response.message.content.some(part => part.type === 'compaction')) {
|
|
1172
|
+
emit({
|
|
1173
|
+
type: 'compaction',
|
|
1174
|
+
by: 'provider',
|
|
1175
|
+
reason: outgrown ? 'maxContext' : 'threshold',
|
|
1176
|
+
...(response.compactedContextTokens === undefined
|
|
1177
|
+
? {}
|
|
1178
|
+
: { contextTokens: response.compactedContextTokens }),
|
|
1179
|
+
}, callback);
|
|
1180
|
+
}
|
|
1181
|
+
outgrown = false;
|
|
805
1182
|
throwIfProgressFailed();
|
|
806
1183
|
if (response.stopReason === 'truncated') {
|
|
807
1184
|
// What it wrote before the output limit, kept with the run so the next run can
|
|
@@ -833,21 +1210,28 @@ export class LLMAgent {
|
|
|
833
1210
|
const calls = response.message.content.filter(part => part.type === 'toolCall');
|
|
834
1211
|
if (calls.length === 0) {
|
|
835
1212
|
// An answer given while agents work, or before the model read what they answered,
|
|
836
|
-
// is a wait: the model goes on with their answers.
|
|
1213
|
+
// is a wait: the model goes on with their answers. So is one given before it read
|
|
1214
|
+
// what the user sent, while a step is left to answer it; otherwise the message waits
|
|
1215
|
+
// in the inbox for the next run.
|
|
1216
|
+
const lastStep = step === this.maxSteps;
|
|
837
1217
|
let answers = background.takeAnswers();
|
|
838
|
-
|
|
1218
|
+
let sent = lastStep ? undefined : takeSent();
|
|
1219
|
+
if (!answers && !sent && background.working && !lastStep) {
|
|
839
1220
|
await background.nextAnswer();
|
|
840
1221
|
throwIfProgressFailed();
|
|
841
1222
|
answers = background.takeAnswers();
|
|
1223
|
+
sent = takeSent();
|
|
842
1224
|
}
|
|
843
|
-
if (answers || background.working) {
|
|
844
|
-
if (
|
|
1225
|
+
if (answers || sent || background.working) {
|
|
1226
|
+
if (lastStep) {
|
|
845
1227
|
throw new UnrecoverableError('The LLM did not finish within its step limit', {
|
|
846
1228
|
cause: `The model was still waiting for the agents it sent after ${String(this.maxSteps)} steps.`,
|
|
847
1229
|
});
|
|
848
1230
|
}
|
|
849
1231
|
if (answers)
|
|
850
1232
|
runMessages.push(answers);
|
|
1233
|
+
if (sent)
|
|
1234
|
+
runMessages.push(sent);
|
|
851
1235
|
continue;
|
|
852
1236
|
}
|
|
853
1237
|
remember();
|
|
@@ -901,7 +1285,9 @@ export class LLMAgent {
|
|
|
901
1285
|
reads,
|
|
902
1286
|
onProgress,
|
|
903
1287
|
spend,
|
|
904
|
-
depth:
|
|
1288
|
+
depth: delegation?.depth ?? 0,
|
|
1289
|
+
...(delegation?.writeCeiling ? { writeCeiling: delegation.writeCeiling } : {}),
|
|
1290
|
+
...(this.writeRoots ? { agentWriteRoots: this.writeRoots } : {}),
|
|
905
1291
|
...(options.approve ? { approve: options.approve } : {}),
|
|
906
1292
|
...(options.ask ? { ask: options.ask } : {}),
|
|
907
1293
|
}, throwIfProgressFailed, loops, background);
|
|
@@ -911,10 +1297,14 @@ export class LLMAgent {
|
|
|
911
1297
|
await keepFailed();
|
|
912
1298
|
throw stopped;
|
|
913
1299
|
}
|
|
914
|
-
// What agents answered meanwhile, after the results the model is
|
|
1300
|
+
// What agents answered and the user sent meanwhile, after the results the model is
|
|
1301
|
+
// waiting for.
|
|
915
1302
|
const answers = background.takeAnswers();
|
|
916
1303
|
if (answers)
|
|
917
1304
|
runMessages.push(answers);
|
|
1305
|
+
const sent = takeSent();
|
|
1306
|
+
if (sent)
|
|
1307
|
+
runMessages.push(sent);
|
|
918
1308
|
}
|
|
919
1309
|
}
|
|
920
1310
|
throw new UnrecoverableError('The LLM did not finish within its step limit', {
|
|
@@ -925,6 +1315,8 @@ export class LLMAgent {
|
|
|
925
1315
|
// The run ends only after the agents still working, which stop with it: what they
|
|
926
1316
|
// changed and spent is the run's.
|
|
927
1317
|
await background.stop();
|
|
1318
|
+
// A run not kept read nothing: what it took belongs to the next attempt or run.
|
|
1319
|
+
inbox?.giveBack(unkept);
|
|
928
1320
|
let failure = error;
|
|
929
1321
|
if (effects.toolRan && error instanceof RecoverableError) {
|
|
930
1322
|
// RetryingAgent would run the whole prompt again, and with it every tool this run
|
|
@@ -940,6 +1332,9 @@ export class LLMAgent {
|
|
|
940
1332
|
// The run spent the tokens of every step before the one that failed.
|
|
941
1333
|
throw withSpentTokens(failure, tokens, unreported || background.usageUnknown);
|
|
942
1334
|
}
|
|
1335
|
+
finally {
|
|
1336
|
+
stopListening?.();
|
|
1337
|
+
}
|
|
943
1338
|
}
|
|
944
1339
|
}
|
|
945
1340
|
//# sourceMappingURL=llmAgent.js.map
|