@oharness/engine 0.9.2 → 0.10.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/dist/agent/agent.d.ts +207 -1
- package/dist/agent/agent.js +691 -55
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/background.d.ts +135 -0
- package/dist/agent/background.js +307 -0
- package/dist/agent/background.js.map +1 -0
- package/dist/agent/compaction.d.ts +7 -0
- package/dist/agent/compaction.js +11 -3
- package/dist/agent/compaction.js.map +1 -1
- package/dist/agent/goal.d.ts +36 -0
- package/dist/agent/goal.js +71 -0
- package/dist/agent/goal.js.map +1 -0
- package/dist/agent/permissions.d.ts +6 -0
- package/dist/agent/permissions.js +2 -1
- package/dist/agent/permissions.js.map +1 -1
- package/dist/agent/session.d.ts +36 -0
- package/dist/agent/session.js +72 -0
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/store.d.ts +25 -0
- package/dist/agent/store.js +21 -0
- package/dist/agent/store.js.map +1 -1
- package/dist/agent/subagent.d.ts +12 -0
- package/dist/agent/subagent.js +11 -1
- package/dist/agent/subagent.js.map +1 -1
- package/dist/agent/workspace.d.ts +9 -0
- package/dist/agent/workspace.js +59 -5
- package/dist/agent/workspace.js.map +1 -1
- package/dist/auth/manager.d.ts +9 -0
- package/dist/auth/manager.js +67 -6
- package/dist/auth/manager.js.map +1 -1
- package/dist/auth/providers/account.js +5 -4
- package/dist/auth/providers/account.js.map +1 -1
- package/dist/browser/tools.js +3 -0
- package/dist/browser/tools.js.map +1 -1
- package/dist/computer/arbiter.d.ts +69 -0
- package/dist/computer/arbiter.js +128 -0
- package/dist/computer/arbiter.js.map +1 -0
- package/dist/computer/cua-driver.d.ts +115 -0
- package/dist/computer/cua-driver.js +453 -0
- package/dist/computer/cua-driver.js.map +1 -0
- package/dist/computer/helper-client.d.ts +78 -0
- package/dist/computer/helper-client.js +163 -0
- package/dist/computer/helper-client.js.map +1 -0
- package/dist/computer/index.d.ts +15 -0
- package/dist/computer/index.js +9 -0
- package/dist/computer/index.js.map +1 -0
- package/dist/computer/mode.d.ts +19 -0
- package/dist/computer/mode.js +42 -0
- package/dist/computer/mode.js.map +1 -0
- package/dist/computer/plugin.d.ts +24 -0
- package/dist/computer/plugin.js +17 -0
- package/dist/computer/plugin.js.map +1 -0
- package/dist/computer/policy.d.ts +29 -0
- package/dist/computer/policy.js +127 -0
- package/dist/computer/policy.js.map +1 -0
- package/dist/computer/session.d.ts +199 -0
- package/dist/computer/session.js +392 -0
- package/dist/computer/session.js.map +1 -0
- package/dist/computer/target.d.ts +187 -0
- package/dist/computer/target.js +31 -0
- package/dist/computer/target.js.map +1 -0
- package/dist/computer/tools.d.ts +8 -0
- package/dist/computer/tools.js +537 -0
- package/dist/computer/tools.js.map +1 -0
- package/dist/core/tokens.js +4 -0
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/types.d.ts +16 -1
- package/dist/core/types.js.map +1 -1
- package/dist/harness.d.ts +76 -1
- package/dist/harness.js +178 -22
- package/dist/harness.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/transport.js +3 -0
- package/dist/mcp/transport.js.map +1 -1
- package/dist/providers/catalog.js +6 -0
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/discovery.js +10 -2
- package/dist/providers/discovery.js.map +1 -1
- package/dist/providers/models-dev.js +1 -0
- package/dist/providers/models-dev.js.map +1 -1
- package/dist/providers/models-dev.json +114 -102
- package/dist/providers/piai.d.ts +16 -1
- package/dist/providers/piai.js +84 -7
- package/dist/providers/piai.js.map +1 -1
- package/dist/providers/types.d.ts +6 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/runtime-plugins/cloud-connector.js +5 -0
- package/dist/runtime-plugins/cloud-connector.js.map +1 -1
- package/dist/schedule/scheduler.d.ts +38 -0
- package/dist/schedule/scheduler.js +101 -1
- package/dist/schedule/scheduler.js.map +1 -1
- package/dist/tools/builtin/bash.d.ts +6 -0
- package/dist/tools/builtin/bash.js +2 -1
- package/dist/tools/builtin/bash.js.map +1 -1
- package/dist/tools/builtin/recall.js +4 -0
- package/dist/tools/builtin/recall.js.map +1 -1
- package/dist/tools/builtin/routine.d.ts +9 -0
- package/dist/tools/builtin/routine.js +23 -2
- package/dist/tools/builtin/routine.js.map +1 -1
- package/dist/tools/builtin/task-control.d.ts +21 -0
- package/dist/tools/builtin/task-control.js +120 -0
- package/dist/tools/builtin/task-control.js.map +1 -0
- package/dist/tools/builtin/task.js +50 -9
- package/dist/tools/builtin/task.js.map +1 -1
- package/dist/tools/builtin/todo.d.ts +1 -1
- package/dist/tools/builtin/worktree.d.ts +10 -0
- package/dist/tools/builtin/worktree.js +52 -0
- package/dist/tools/builtin/worktree.js.map +1 -0
- package/dist/tools/define.d.ts +4 -1
- package/dist/tools/define.js +2 -0
- package/dist/tools/define.js.map +1 -1
- package/dist/tools/types.d.ts +112 -0
- package/dist/tools/types.js.map +1 -1
- package/dist/worktree/index.d.ts +109 -0
- package/dist/worktree/index.js +463 -0
- package/dist/worktree/index.js.map +1 -0
- package/package.json +3 -3
package/dist/agent/agent.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
import { GOAL_MAX_TURNS, GOAL_TOOL, MAX_GOAL_NUDGES, activeGoal, goalNudge, goalPromptSection, goalTool } from "./goal.js";
|
|
2
|
+
import { COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
|
|
1
3
|
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { errorMessage } from "../core/errors.js";
|
|
3
|
-
import { estimateStringTokens } from "../core/tokens.js";
|
|
5
|
+
import { estimateMessagesTokens, estimateStringTokens, estimateToolsTokens } from "../core/tokens.js";
|
|
4
6
|
import { assistantMessage, emptyUsage, messageText, userMessage } from "../core/types.js";
|
|
5
7
|
import { thinkingFor } from "../providers/types.js";
|
|
8
|
+
import { WORKTREE_START_TOOL, worktreeStartTool } from "../tools/builtin/worktree.js";
|
|
9
|
+
import { BASH_CWD_KEY } from "../tools/builtin/bash.js";
|
|
10
|
+
import { createWorktree, finishWorktree, worktreeStatus } from "../worktree/index.js";
|
|
6
11
|
import { createTaskTool } from "../tools/builtin/task.js";
|
|
12
|
+
import { TASK_MESSAGE_TOOL, TASK_OUTPUT_TOOL, TASK_STOP_TOOL, taskMessageTool, taskOutputTool, taskStopTool, } from "../tools/builtin/task-control.js";
|
|
13
|
+
import { BackgroundTasks, formatNotification } from "./background.js";
|
|
7
14
|
import { ARCHIVE_READER } from "../tools/builtin/recall.js";
|
|
8
15
|
import { ARTIFACT_METADATA_KEY } from "../tools/builtin/artifact.js";
|
|
9
16
|
import { previewJson } from "../util/json.js";
|
|
10
17
|
import { AsyncQueue, mergeStreams } from "../util/queue.js";
|
|
11
18
|
import { createLogger } from "../util/logger.js";
|
|
12
|
-
import { compact, shouldCompact } from "./compaction.js";
|
|
19
|
+
import { compact, compactionPoint, shouldCompact } from "./compaction.js";
|
|
13
20
|
import { capToolResult } from "./overflow.js";
|
|
14
|
-
import { checkWrite, resolveWithin } from "./workspace.js";
|
|
21
|
+
import { checkWrite, resolveWithin, retargetPolicy } from "./workspace.js";
|
|
15
22
|
import { SkillRegistry } from "./skills.js";
|
|
16
23
|
import { MemoryStore, recallFromStores } from "./memory.js";
|
|
17
24
|
import { DEFAULT_MEMORY_POLICY } from "./memory-limits.js";
|
|
@@ -23,7 +30,18 @@ import { HookRegistry } from "./hooks.js";
|
|
|
23
30
|
import { buildSystemPrompt } from "./prompt.js";
|
|
24
31
|
import { ASK_USER_TOOL } from "../tools/questions.js";
|
|
25
32
|
import { SubagentRegistry } from "./subagent.js";
|
|
33
|
+
function withoutSignal(opts) {
|
|
34
|
+
const { signal: _signal, ...rest } = opts;
|
|
35
|
+
return rest;
|
|
36
|
+
}
|
|
26
37
|
const DEFAULT_MAX_TURNS = 60;
|
|
38
|
+
/**
|
|
39
|
+
* How long an idle agent lets background results gather before asking for a
|
|
40
|
+
* turn to read them, so tasks ending together are answered together.
|
|
41
|
+
*/
|
|
42
|
+
const TASK_WAKE_DELAY_MS = 500;
|
|
43
|
+
/** How long task changes gather before they are written down. */
|
|
44
|
+
const TASK_SAVE_DELAY_MS = 1000;
|
|
27
45
|
/** Session state key: ids of notes attached since the last compaction. */
|
|
28
46
|
const RECALLED = "memory:recalled";
|
|
29
47
|
/** Session state key: how much of the transcript an end-of-run extraction has covered. */
|
|
@@ -57,14 +75,6 @@ function recalledIds(state, messages) {
|
|
|
57
75
|
}
|
|
58
76
|
return ids;
|
|
59
77
|
}
|
|
60
|
-
/**
|
|
61
|
-
* The agentic loop: prompt the model, run whatever tools it asks for, feed the
|
|
62
|
-
* results back, repeat until it stops calling tools.
|
|
63
|
-
*
|
|
64
|
-
* Everything provider-specific lives behind `Provider`, so this loop is the
|
|
65
|
-
* same regardless of which model is selected — including when the model
|
|
66
|
-
* changes partway through a session.
|
|
67
|
-
*/
|
|
68
78
|
export class Agent {
|
|
69
79
|
session;
|
|
70
80
|
tools;
|
|
@@ -95,6 +105,18 @@ export class Agent {
|
|
|
95
105
|
pluginSections = [];
|
|
96
106
|
/** Recall over `memory` for agents embedded without a plugin host. */
|
|
97
107
|
legacyMemory;
|
|
108
|
+
/** Subagents running in the background; made on first use. */
|
|
109
|
+
background;
|
|
110
|
+
wakeTimer;
|
|
111
|
+
/** Frontends watching background tasks, kept until the tasks exist. */
|
|
112
|
+
changeListeners = new Set();
|
|
113
|
+
/** What each background task was asked, and its subagent once it has one. */
|
|
114
|
+
taskStates = new Map();
|
|
115
|
+
restoring;
|
|
116
|
+
saveTimer;
|
|
117
|
+
/** Closed or deleted: nothing more is saved, and nothing more starts. */
|
|
118
|
+
tasksClosed = false;
|
|
119
|
+
saveChain = Promise.resolve();
|
|
98
120
|
constructor(opts) {
|
|
99
121
|
this.opts = opts;
|
|
100
122
|
this.session = opts.session;
|
|
@@ -128,11 +150,25 @@ export class Agent {
|
|
|
128
150
|
if (!this.tools.has("skill") && this.skills.size > 0) {
|
|
129
151
|
this.tools.register(createSkillTool(this.skills));
|
|
130
152
|
}
|
|
153
|
+
if (!this.tools.has(GOAL_TOOL))
|
|
154
|
+
this.tools.register(goalTool);
|
|
155
|
+
// Shown only where a frontend can host the session it starts; see visibleTools.
|
|
156
|
+
if (!this.tools.has(WORKTREE_START_TOOL))
|
|
157
|
+
this.tools.register(worktreeStartTool);
|
|
131
158
|
// The task tool has to be built here: its description enumerates the
|
|
132
159
|
// subagent types that actually exist in this configuration.
|
|
133
160
|
if (!this.tools.has("task") && this.subagents.list().length > 0) {
|
|
134
161
|
this.tools.register(createTaskTool(this));
|
|
135
162
|
}
|
|
163
|
+
// Shown only where background tasks can be offered; see visibleTools.
|
|
164
|
+
if (this.tools.has("task")) {
|
|
165
|
+
if (!this.tools.has(TASK_OUTPUT_TOOL))
|
|
166
|
+
this.tools.register(taskOutputTool);
|
|
167
|
+
if (!this.tools.has(TASK_STOP_TOOL))
|
|
168
|
+
this.tools.register(taskStopTool);
|
|
169
|
+
if (!this.tools.has(TASK_MESSAGE_TOOL))
|
|
170
|
+
this.tools.register(taskMessageTool);
|
|
171
|
+
}
|
|
136
172
|
}
|
|
137
173
|
/**
|
|
138
174
|
* Announce a model switch so a UI can render it and hooks can react.
|
|
@@ -238,35 +274,387 @@ export class Agent {
|
|
|
238
274
|
resolvePath(input, access) {
|
|
239
275
|
return resolveWithin(this.opts.workspace, this.session.cwd, input, access);
|
|
240
276
|
}
|
|
277
|
+
async startWorktree(opts) {
|
|
278
|
+
const launch = this.opts.worktreeLauncher?.();
|
|
279
|
+
if (!launch)
|
|
280
|
+
throw new Error("this frontend cannot start a session in a worktree");
|
|
281
|
+
return launch({ ...opts, from: { cwd: this.session.cwd, sessionId: this.session.id } });
|
|
282
|
+
}
|
|
241
283
|
listSubagentTypes() {
|
|
242
284
|
return this.subagents.summaries();
|
|
243
285
|
}
|
|
244
286
|
async runSubagent(opts) {
|
|
287
|
+
const child = await this.prepareSubagent(opts);
|
|
288
|
+
try {
|
|
289
|
+
const result = await this.runChild(child, opts.prompt, opts.signal);
|
|
290
|
+
// Propagate state the subagent built up (e.g. files it read). Not from a
|
|
291
|
+
// worktree: what it read there are paths in another tree.
|
|
292
|
+
if (!child.worktree)
|
|
293
|
+
for (const [key, value] of child.agent.session.state)
|
|
294
|
+
this.session.state.set(key, value);
|
|
295
|
+
// A worktree it left changes in is named in the result: the parent has
|
|
296
|
+
// to decide what happens to that work.
|
|
297
|
+
const kept = await this.worktreeNote(child);
|
|
298
|
+
const text = kept
|
|
299
|
+
? `${result.text}\n\n[worktree] ${kept} It is kept: review it, then merge, push or apply it, or discard it from the sidebar.`
|
|
300
|
+
: result.text;
|
|
301
|
+
return { text, turns: result.turns, usage: result.usage };
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
await this.disposeChild(child);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// --- Background tasks ----------------------------------------------------
|
|
308
|
+
/**
|
|
309
|
+
* Subagents running behind this conversation, offered only while someone
|
|
310
|
+
* can start a turn to read their results in. Once any exist they stay
|
|
311
|
+
* reachable, so they can still be listed, messaged and stopped.
|
|
312
|
+
*/
|
|
313
|
+
get backgroundTasks() {
|
|
314
|
+
if (!this.backgroundAvailable())
|
|
315
|
+
return undefined;
|
|
316
|
+
// Read without making the table: every open conversation is asked for
|
|
317
|
+
// its list, and most never start a task.
|
|
318
|
+
return {
|
|
319
|
+
start: (opts) => {
|
|
320
|
+
const state = { opts: withoutSignal(opts) };
|
|
321
|
+
const view = this.tasks().start(this.backgroundSpec(state));
|
|
322
|
+
this.taskStates.set(view.id, state);
|
|
323
|
+
return view;
|
|
324
|
+
},
|
|
325
|
+
get: (id) => this.background?.get(id),
|
|
326
|
+
list: () => this.background?.list() ?? [],
|
|
327
|
+
stop: (id) => this.background?.stop(id) ?? false,
|
|
328
|
+
send: (id, message) => this.background?.send(id, message) ?? "unknown",
|
|
329
|
+
dismiss: (id) => this.background?.dismiss(id) ?? false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** A task's conversation so far, for a frontend to show. */
|
|
333
|
+
backgroundTranscript(id) {
|
|
334
|
+
return this.background?.transcript(id);
|
|
335
|
+
}
|
|
336
|
+
/** Every change to a background task, for a frontend to redraw from. */
|
|
337
|
+
onBackgroundChange(listener) {
|
|
338
|
+
this.changeListeners.add(listener);
|
|
339
|
+
const off = this.background?.onChange(listener);
|
|
340
|
+
return () => {
|
|
341
|
+
this.changeListeners.delete(listener);
|
|
342
|
+
off?.();
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The conversation is being closed, not ended: write its tasks down as they
|
|
347
|
+
* stand, then stop them. Opened again, the ones that were running come back
|
|
348
|
+
* interrupted and can be followed up — as after a restart.
|
|
349
|
+
*/
|
|
350
|
+
async closeBackgroundTasks() {
|
|
351
|
+
if (this.tasksClosed)
|
|
352
|
+
return;
|
|
353
|
+
if (this.saveTimer)
|
|
354
|
+
clearTimeout(this.saveTimer);
|
|
355
|
+
this.saveTimer = undefined;
|
|
356
|
+
await this.saveTasks();
|
|
357
|
+
this.endBackgroundTasks();
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The conversation is being deleted: stop its tasks and write nothing more.
|
|
361
|
+
* Resolves once a write already under way has landed, so deleting the
|
|
362
|
+
* store afterwards cannot be undone by it.
|
|
363
|
+
*/
|
|
364
|
+
async discardBackgroundTasks() {
|
|
365
|
+
this.endBackgroundTasks();
|
|
366
|
+
await this.saveChain;
|
|
367
|
+
}
|
|
368
|
+
endBackgroundTasks() {
|
|
369
|
+
this.tasksClosed = true;
|
|
370
|
+
if (this.wakeTimer)
|
|
371
|
+
clearTimeout(this.wakeTimer);
|
|
372
|
+
if (this.saveTimer)
|
|
373
|
+
clearTimeout(this.saveTimer);
|
|
374
|
+
this.wakeTimer = undefined;
|
|
375
|
+
this.saveTimer = undefined;
|
|
376
|
+
this.background?.stopAll();
|
|
377
|
+
this.background?.detach();
|
|
378
|
+
}
|
|
379
|
+
backgroundAvailable() {
|
|
380
|
+
return Boolean(this.background) || Boolean(this.opts.taskWaker?.());
|
|
381
|
+
}
|
|
382
|
+
tasks() {
|
|
383
|
+
if (!this.background) {
|
|
384
|
+
this.background = new BackgroundTasks({
|
|
385
|
+
...(this.opts.maxBackgroundTasks ? { maxConcurrent: this.opts.maxBackgroundTasks } : {}),
|
|
386
|
+
...(this.opts.lifecycleSignal ? { signal: this.opts.lifecycleSignal } : {}),
|
|
387
|
+
});
|
|
388
|
+
// While a run is going its own loop reads results in at the next gap;
|
|
389
|
+
// only an idle agent needs someone to start a turn for them. And an
|
|
390
|
+
// ended task is written down at once rather than after the usual pause:
|
|
391
|
+
// its result is the one thing a restart must not lose.
|
|
392
|
+
this.background.onSettled(() => {
|
|
393
|
+
if (!this.running)
|
|
394
|
+
this.scheduleWake();
|
|
395
|
+
void this.saveTasks();
|
|
396
|
+
});
|
|
397
|
+
for (const listener of this.changeListeners)
|
|
398
|
+
this.background.onChange(listener);
|
|
399
|
+
this.background.onChange(() => this.scheduleSave());
|
|
400
|
+
}
|
|
401
|
+
return this.background;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A background task's subagent, made for each run and let go after it.
|
|
405
|
+
*
|
|
406
|
+
* Between runs a task holds only its conversation and, if it changed
|
|
407
|
+
* anything, its worktree: no browser, no claim on the computer, no agent.
|
|
408
|
+
* A follow-up — or the first one after a restart — builds the subagent
|
|
409
|
+
* again from those, so both paths are the same path.
|
|
410
|
+
*/
|
|
411
|
+
backgroundSpec(state) {
|
|
412
|
+
const once = async (input, signal, report) => {
|
|
413
|
+
const child = await this.prepareSubagent(state.opts, {
|
|
414
|
+
messages: state.messages ?? [],
|
|
415
|
+
...(state.worktree ? { worktree: state.worktree } : {}),
|
|
416
|
+
...(state.equipment ? { equipment: state.equipment } : {}),
|
|
417
|
+
});
|
|
418
|
+
// Its tree is gone — removed by hand, or after a restart — and it now
|
|
419
|
+
// works in a new one: said up front, since its conversation names the
|
|
420
|
+
// old paths.
|
|
421
|
+
const moved = state.worktree && child.worktree && state.worktree.path !== child.worktree.path
|
|
422
|
+
? `[Note: your earlier worktree at ${state.worktree.path} no longer exists. You are now working in ${child.worktree.path}; files you changed there are not here.]\n\n`
|
|
423
|
+
: "";
|
|
424
|
+
state.child = child;
|
|
425
|
+
state.equipment = child.equipment;
|
|
426
|
+
if (child.worktree)
|
|
427
|
+
state.worktree = child.worktree;
|
|
428
|
+
try {
|
|
429
|
+
const result = await this.runChild(child, moved + input, signal, report);
|
|
430
|
+
const kept = await this.worktreeNote(child);
|
|
431
|
+
return { ...result, ...(kept ? { worktree: kept } : {}) };
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
// The agent goes; its conversation, tree and browser stay with the
|
|
435
|
+
// task until it is stopped, or its conversation closed.
|
|
436
|
+
state.messages = [...child.agent.session.messages];
|
|
437
|
+
state.child = undefined;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
agentType: state.opts.agentType,
|
|
442
|
+
description: state.opts.description || state.opts.agentType,
|
|
443
|
+
run: (signal, report) => once(state.opts.prompt, signal, report),
|
|
444
|
+
// A task that failed before its subagent ever ran has no conversation to
|
|
445
|
+
// continue; the follow-up then goes with the task it was given.
|
|
446
|
+
resume: (message, signal, report) => once(state.messages?.length ? message : `${state.opts.prompt}\n\n${message}`, signal, report),
|
|
447
|
+
interject: (message) => Boolean(state.child?.agent.interject(message)),
|
|
448
|
+
transcript: () => state.child?.agent.session.messages ?? state.messages ?? [],
|
|
449
|
+
dispose: async () => {
|
|
450
|
+
await state.equipment?.release().catch(() => { });
|
|
451
|
+
state.equipment = undefined;
|
|
452
|
+
if (state.worktree)
|
|
453
|
+
await this.dropIfUntouched(state.worktree);
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Put back the background tasks this conversation had before a restart.
|
|
459
|
+
*
|
|
460
|
+
* Called by a frontend when it opens the conversation; safe to call again.
|
|
461
|
+
* A task that was still running when the app closed comes back as failed
|
|
462
|
+
* and says why — its subagent's process is gone — but keeps its
|
|
463
|
+
* conversation, so a follow-up carries on from where it stopped. A result
|
|
464
|
+
* nobody had been told about is still read in with the next turn.
|
|
465
|
+
*/
|
|
466
|
+
async restoreBackgroundTasks() {
|
|
467
|
+
if (this.restoring)
|
|
468
|
+
return this.restoring;
|
|
469
|
+
this.restoring = (async () => {
|
|
470
|
+
if (this.tasksClosed)
|
|
471
|
+
return 0;
|
|
472
|
+
const saved = await this.session.loadTasks().catch(() => []);
|
|
473
|
+
// Closed while it was reading: bringing them back now would make a
|
|
474
|
+
// table nothing will ever close.
|
|
475
|
+
if (this.tasksClosed)
|
|
476
|
+
return 0;
|
|
477
|
+
let restored = 0;
|
|
478
|
+
for (const raw of saved) {
|
|
479
|
+
const item = raw;
|
|
480
|
+
if (!item?.view?.id || !item.options?.agentType || !this.subagents.get(item.options.agentType))
|
|
481
|
+
continue;
|
|
482
|
+
const view = { ...item.view };
|
|
483
|
+
let delivered = item.delivered === true;
|
|
484
|
+
if (view.status === "running" || view.status === "queued") {
|
|
485
|
+
view.status = "failed";
|
|
486
|
+
view.error = "Interrupted: OHarness closed while this task was running. Send it a message to carry on from where it stopped.";
|
|
487
|
+
view.endedAt = Date.now();
|
|
488
|
+
delete view.activity;
|
|
489
|
+
delivered = true;
|
|
490
|
+
}
|
|
491
|
+
const state = {
|
|
492
|
+
opts: item.options,
|
|
493
|
+
messages: Array.isArray(item.messages) ? item.messages : [],
|
|
494
|
+
...(item.worktree ? { worktree: item.worktree } : {}),
|
|
495
|
+
};
|
|
496
|
+
this.taskStates.set(view.id, state);
|
|
497
|
+
this.tasks().restore(view, this.backgroundSpec(state), delivered);
|
|
498
|
+
restored++;
|
|
499
|
+
}
|
|
500
|
+
return restored;
|
|
501
|
+
})();
|
|
502
|
+
return this.restoring;
|
|
503
|
+
}
|
|
504
|
+
/** Write the tasks down a moment after they change, so a burst is one write. */
|
|
505
|
+
scheduleSave() {
|
|
506
|
+
if (this.saveTimer || this.tasksClosed)
|
|
507
|
+
return;
|
|
508
|
+
this.saveTimer = setTimeout(() => {
|
|
509
|
+
this.saveTimer = undefined;
|
|
510
|
+
void this.saveTasks();
|
|
511
|
+
}, TASK_SAVE_DELAY_MS);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* One write at a time, in order. Each takes its snapshot when its turn
|
|
515
|
+
* comes, so a later state is never overwritten by an earlier one landing
|
|
516
|
+
* last — a task that ended would come back as running.
|
|
517
|
+
*/
|
|
518
|
+
saveTasks() {
|
|
519
|
+
const next = this.saveChain.then(() => this.writeTasks());
|
|
520
|
+
this.saveChain = next.catch(() => { });
|
|
521
|
+
return next;
|
|
522
|
+
}
|
|
523
|
+
async writeTasks() {
|
|
524
|
+
// Shutting down stops every task; writing that down would bring them back
|
|
525
|
+
// as stopped for good, when they were only interrupted. The last save from
|
|
526
|
+
// while they ran is the truer record. A closed conversation has already
|
|
527
|
+
// written its own.
|
|
528
|
+
if (!this.background || this.tasksClosed || this.opts.lifecycleSignal?.aborted)
|
|
529
|
+
return;
|
|
530
|
+
const tasks = this.background.saved().flatMap(({ view, delivered }) => {
|
|
531
|
+
const state = this.taskStates.get(view.id);
|
|
532
|
+
if (!state)
|
|
533
|
+
return [];
|
|
534
|
+
const worktree = state.child?.worktree ?? state.worktree;
|
|
535
|
+
return [{
|
|
536
|
+
view,
|
|
537
|
+
delivered,
|
|
538
|
+
options: state.opts,
|
|
539
|
+
messages: [...(state.child?.agent.session.messages ?? state.messages ?? [])],
|
|
540
|
+
...(worktree ? { worktree } : {}),
|
|
541
|
+
}];
|
|
542
|
+
});
|
|
543
|
+
await this.session.saveTasks(tasks).catch((err) => {
|
|
544
|
+
this.logger.warn("could not save background tasks", { error: errorMessage(err) });
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
scheduleWake() {
|
|
548
|
+
if (this.wakeTimer || this.tasksClosed)
|
|
549
|
+
return;
|
|
550
|
+
this.wakeTimer = setTimeout(() => {
|
|
551
|
+
this.wakeTimer = undefined;
|
|
552
|
+
if (this.running || !this.background?.hasUndelivered())
|
|
553
|
+
return;
|
|
554
|
+
this.opts.taskWaker?.()?.(this.session.id);
|
|
555
|
+
}, TASK_WAKE_DELAY_MS);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Read the results of background tasks that have ended into the
|
|
559
|
+
* conversation, as one message saying what is still running.
|
|
560
|
+
*
|
|
561
|
+
* Synthetic, like a goal nudge: nobody typed it, so it skips the prompt hook
|
|
562
|
+
* and memory recall, and never titles the session.
|
|
563
|
+
*/
|
|
564
|
+
async *takeNotices() {
|
|
565
|
+
const ended = this.background?.takeUndelivered() ?? [];
|
|
566
|
+
if (!ended.length)
|
|
567
|
+
return false;
|
|
568
|
+
const text = formatNotification(ended, this.background?.active() ?? []);
|
|
569
|
+
await this.session.addMessage({
|
|
570
|
+
role: "user",
|
|
571
|
+
content: [{ type: "text", text }],
|
|
572
|
+
// What a transcript needs to draw it as a card rather than as text
|
|
573
|
+
// somebody typed: which tasks, and how each ended.
|
|
574
|
+
meta: {
|
|
575
|
+
timestamp: Date.now(),
|
|
576
|
+
synthetic: true,
|
|
577
|
+
taskNotification: ended.map((task) => ({ id: task.id, description: task.description, status: task.status })),
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
yield { type: "task_notification", tasks: ended, text };
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
/** Where this agent's permission cards go, and who they say is asking. */
|
|
584
|
+
interactionPlace() {
|
|
585
|
+
return {
|
|
586
|
+
sessionId: this.opts.interactionSessionId ?? this.session.id,
|
|
587
|
+
...(this.opts.interactionLabel ? { from: this.opts.interactionLabel } : {}),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
// --- Subagents -----------------------------------------------------------
|
|
591
|
+
/** A subagent ready to run: its agent, the tree it works in, what it holds. */
|
|
592
|
+
async prepareSubagent(opts, seed) {
|
|
245
593
|
const definition = this.subagents.get(opts.agentType);
|
|
246
594
|
if (!definition)
|
|
247
595
|
throw new Error(`unknown agent type: ${opts.agentType}`);
|
|
248
596
|
// A subagent gets its own transcript and a filtered tool set, but shares
|
|
249
597
|
// the parent's scratch state so file-read tracking stays coherent.
|
|
250
598
|
const { Session: SessionClass } = await import("./session.js");
|
|
599
|
+
// Its own tree when asked for, or when the agent type always wants one:
|
|
600
|
+
// several editing subagents in one checkout would trip over each other.
|
|
601
|
+
// Branched from this checkout's HEAD rather than the remote, so it starts
|
|
602
|
+
// from the commits the parent is looking at.
|
|
603
|
+
const isolated = opts.isolation === "worktree" || definition.isolation === "worktree";
|
|
604
|
+
// A restored task goes back to its own tree while that still exists.
|
|
605
|
+
const kept = seed?.worktree && (await worktreeStatus(seed.worktree).catch(() => undefined))?.exists ? seed.worktree : undefined;
|
|
606
|
+
const worktree = kept ?? (isolated
|
|
607
|
+
? await createWorktree(this.session.cwd, {
|
|
608
|
+
task: opts.description || definition.name,
|
|
609
|
+
slug: opts.description ? undefined : `${definition.name}-agent`,
|
|
610
|
+
base: "HEAD",
|
|
611
|
+
fetch: false,
|
|
612
|
+
})
|
|
613
|
+
: undefined);
|
|
614
|
+
const childCwd = worktree?.cwd ?? this.session.cwd;
|
|
251
615
|
const child = new SessionClass({
|
|
252
|
-
cwd:
|
|
616
|
+
cwd: childCwd,
|
|
253
617
|
model: opts.model ?? definition.model ?? this.session.model.id,
|
|
254
618
|
providers: this.session.providers,
|
|
255
619
|
permissionMode: this.session.permissions.getMode(),
|
|
256
620
|
store: false,
|
|
621
|
+
// Through the constructor rather than added one by one: it drops a
|
|
622
|
+
// tail a run was cut off in — a tool call with no result — which no
|
|
623
|
+
// provider accepts a following message after.
|
|
624
|
+
...(seed?.messages.length ? { messages: [...seed.messages] } : {}),
|
|
257
625
|
});
|
|
258
626
|
for (const [key, value] of this.session.state)
|
|
259
627
|
child.state.set(key, value);
|
|
628
|
+
// A shell that had cd'd somewhere in the parent's tree must start in its own.
|
|
629
|
+
if (worktree)
|
|
630
|
+
child.state.delete(BASH_CWD_KEY);
|
|
631
|
+
// Control of the screen is never inherited by default: a subagent works
|
|
632
|
+
// unattended, and drives the computer only when asked to and only while
|
|
633
|
+
// the conversation that asked has it switched on.
|
|
634
|
+
if (!(opts.computer && computerOn(this.session.state)))
|
|
635
|
+
child.state.delete(COMPUTER_KEY);
|
|
260
636
|
child.permissions.onDecision((entry) => {
|
|
261
637
|
this.audit({ type: "permission", timestamp: Date.now(), sessionId: child.id, entry });
|
|
262
638
|
});
|
|
263
639
|
// A subagent does not ask the person questions. Its events never reach the
|
|
264
640
|
// parent's stream, so a card it raised would have no call to belong to —
|
|
265
641
|
// and needing to ask usually means the delegation was underspecified,
|
|
266
|
-
// which is the parent's to resolve.
|
|
642
|
+
// which is the parent's to resolve. Nor does it see the parent's
|
|
643
|
+
// background tasks: it has none of its own.
|
|
267
644
|
const childTools = definition.tools ? this.tools.filtered(definition.tools) : this.tools.clone();
|
|
268
645
|
childTools.unregister(ASK_USER_TOOL);
|
|
269
|
-
|
|
646
|
+
childTools.unregister(TASK_OUTPUT_TOOL);
|
|
647
|
+
childTools.unregister(TASK_STOP_TOOL);
|
|
648
|
+
childTools.unregister(TASK_MESSAGE_TOOL);
|
|
649
|
+
const label = opts.description ? `${definition.name}: ${opts.description}` : definition.name;
|
|
650
|
+
// Its own browser tabs and its own place in the computer's leases, so two
|
|
651
|
+
// subagents — or a subagent and its parent — never act on one page.
|
|
652
|
+
let equipment = seed?.equipment;
|
|
653
|
+
if (equipment?.bind)
|
|
654
|
+
equipment.bind(childTools);
|
|
655
|
+
else
|
|
656
|
+
equipment = this.opts.equipSubagent?.(child.id, label, childTools);
|
|
657
|
+
const agent = new Agent({
|
|
270
658
|
...this.opts,
|
|
271
659
|
questionPrompt: undefined,
|
|
272
660
|
session: child,
|
|
@@ -275,40 +663,96 @@ export class Agent {
|
|
|
275
663
|
appendSystemPrompt: undefined,
|
|
276
664
|
maxTurns: definition.maxTurns ?? this.opts.maxTurns,
|
|
277
665
|
agentId: `${this.agentId}/${definition.name}`,
|
|
666
|
+
...(worktree && this.opts.workspace
|
|
667
|
+
? { workspace: retargetPolicy(this.opts.workspace, this.session.cwd, worktree.cwd) }
|
|
668
|
+
: {}),
|
|
669
|
+
// A subagent hands work back to its parent; it does not start sessions
|
|
670
|
+
// or background tasks.
|
|
671
|
+
worktreeLauncher: undefined,
|
|
672
|
+
taskWaker: undefined,
|
|
673
|
+
equipSubagent: undefined,
|
|
674
|
+
...(equipment?.onRunStart ? { onRunStart: equipment.onRunStart } : {}),
|
|
675
|
+
interactionSessionId: this.opts.interactionSessionId ?? this.session.id,
|
|
676
|
+
interactionLabel: label,
|
|
278
677
|
// One level of delegation: a subagent may not spawn further subagents.
|
|
279
678
|
subagents: new SubagentRegistry([]),
|
|
280
679
|
// The parent already attached what matched the user's message, and a
|
|
281
680
|
// subagent's prompt is the parent's wording rather than the user's.
|
|
282
681
|
memoryProvider: undefined,
|
|
283
682
|
});
|
|
683
|
+
return { agent, definition, ...(worktree ? { worktree } : {}), ...(equipment ? { equipment } : {}) };
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* One run of a subagent, to its end: its first prompt, or a later message.
|
|
687
|
+
*
|
|
688
|
+
* `report` is there only for a background task: it is what lets a look at
|
|
689
|
+
* the task say what the subagent is doing now.
|
|
690
|
+
*/
|
|
691
|
+
async runChild(child, input, signal, report) {
|
|
284
692
|
let text = "";
|
|
285
693
|
let turns = 0;
|
|
286
694
|
let usage = emptyUsage();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
695
|
+
// Said to it after its last look at its inbox would otherwise sit there
|
|
696
|
+
// until some later run, out of context: answered now, as one more run.
|
|
697
|
+
for (let next = input; next !== undefined;) {
|
|
698
|
+
for await (const event of child.agent.run(next, { ...(signal ? { signal } : {}) })) {
|
|
699
|
+
if (event.type === "assistant_message") {
|
|
700
|
+
const messageBody = messageText(event.message);
|
|
701
|
+
if (messageBody.trim())
|
|
702
|
+
text = messageBody;
|
|
703
|
+
}
|
|
704
|
+
else if (event.type === "turn_start") {
|
|
705
|
+
report?.turn();
|
|
706
|
+
}
|
|
707
|
+
else if (event.type === "tool_start") {
|
|
708
|
+
report?.activity(event.title);
|
|
709
|
+
}
|
|
710
|
+
else if (event.type === "done") {
|
|
711
|
+
turns += event.turns;
|
|
712
|
+
usage = event.usage;
|
|
713
|
+
}
|
|
296
714
|
}
|
|
715
|
+
const unread = signal?.aborted ? [] : child.agent.takeUnread();
|
|
716
|
+
next = unread.length ? unread.map((entry) => entry.text).join("\n\n") : undefined;
|
|
297
717
|
}
|
|
298
|
-
// Propagate state the subagent built up (e.g. files it read).
|
|
299
|
-
for (const [key, value] of child.state)
|
|
300
|
-
this.session.state.set(key, value);
|
|
301
718
|
await this.hooks.run({
|
|
302
719
|
event: "SubagentStop",
|
|
303
720
|
sessionId: this.session.id,
|
|
304
|
-
agentId: `${this.agentId}/${definition.name}`,
|
|
721
|
+
agentId: `${this.agentId}/${child.definition.name}`,
|
|
305
722
|
cwd: this.session.cwd,
|
|
306
723
|
});
|
|
307
|
-
return {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
724
|
+
return { text, turns, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
|
|
725
|
+
}
|
|
726
|
+
/** What a subagent left in its worktree, when it left anything. */
|
|
727
|
+
async worktreeNote(child) {
|
|
728
|
+
if (!child.worktree)
|
|
729
|
+
return undefined;
|
|
730
|
+
const status = await worktreeStatus(child.worktree).catch(() => undefined);
|
|
731
|
+
if (status?.clean)
|
|
732
|
+
return undefined;
|
|
733
|
+
const what = status
|
|
734
|
+
? [status.commits ? `${status.commits} commit(s)` : "", status.changes.length ? `${status.changes.length} changed file(s)` : ""]
|
|
735
|
+
.filter(Boolean)
|
|
736
|
+
.join(", ")
|
|
737
|
+
: "changes";
|
|
738
|
+
const worktree = child.worktree;
|
|
739
|
+
const where = worktree.branch ? `${worktree.path} on branch ${worktree.branch}` : `${worktree.path} (a copy of the folder)`;
|
|
740
|
+
return `The subagent worked in ${where} and left ${what} there.`;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* A subagent is finished with: its browser and computer go, and a worktree
|
|
744
|
+
* it changed nothing in is removed. One that it changed is kept — the
|
|
745
|
+
* parent has to decide what happens to that work.
|
|
746
|
+
*/
|
|
747
|
+
async disposeChild(child) {
|
|
748
|
+
await child.equipment?.release().catch(() => { });
|
|
749
|
+
if (child.worktree)
|
|
750
|
+
await this.dropIfUntouched(child.worktree);
|
|
751
|
+
}
|
|
752
|
+
async dropIfUntouched(worktree) {
|
|
753
|
+
const status = await worktreeStatus(worktree).catch(() => undefined);
|
|
754
|
+
if (status?.clean)
|
|
755
|
+
await finishWorktree(worktree, "remove").catch(() => { });
|
|
312
756
|
}
|
|
313
757
|
// --- Main loop ----------------------------------------------------------
|
|
314
758
|
async *run(input, runOpts = {}) {
|
|
@@ -338,7 +782,10 @@ export class Agent {
|
|
|
338
782
|
yield { type: "notice", message: start.additionalContext };
|
|
339
783
|
}
|
|
340
784
|
}
|
|
341
|
-
const
|
|
785
|
+
const baseMaxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
786
|
+
// Read per turn rather than once: a goal can be reached, or reported
|
|
787
|
+
// blocked, partway through the run, and the ordinary ceiling returns then.
|
|
788
|
+
const turnLimit = () => (activeGoal(this.state) ? Math.max(baseMaxTurns, GOAL_MAX_TURNS) : baseMaxTurns);
|
|
342
789
|
let stopReason = "end_turn";
|
|
343
790
|
let turn = 0;
|
|
344
791
|
const finishRun = this.opts.onRunStart?.();
|
|
@@ -359,7 +806,13 @@ export class Agent {
|
|
|
359
806
|
yield { type: "memory_extracted", notes: extracted };
|
|
360
807
|
}
|
|
361
808
|
yield* this.ingestInput(input);
|
|
362
|
-
|
|
809
|
+
// Background results that ended since the last run ride along with
|
|
810
|
+
// whatever started this one. A run started only to read them in is
|
|
811
|
+
// given no input of its own, and has nothing to answer if another path
|
|
812
|
+
// got to them first.
|
|
813
|
+
const noticed = yield* this.takeNotices();
|
|
814
|
+
const nothingToAnswer = Array.isArray(input) && input.length === 0 && !noticed;
|
|
815
|
+
while (!nothingToAnswer && turn < turnLimit()) {
|
|
363
816
|
if (signal.aborted) {
|
|
364
817
|
stopReason = "aborted";
|
|
365
818
|
break;
|
|
@@ -428,15 +881,28 @@ export class Agent {
|
|
|
428
881
|
// rather than ending and starting a new run keeps one turn's worth
|
|
429
882
|
// of context, and matches what the user saw: they typed it before
|
|
430
883
|
// the reply landed.
|
|
431
|
-
if (!signal.aborted
|
|
432
|
-
|
|
884
|
+
if (!signal.aborted) {
|
|
885
|
+
const said = yield* this.takeInbox();
|
|
886
|
+
const noticed = yield* this.takeNotices();
|
|
887
|
+
if (said || noticed)
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
433
890
|
stopReason = assembled.stopReason === "tool_use" ? "end_turn" : assembled.stopReason;
|
|
891
|
+
if (!signal.aborted && (yield* this.pushTowardGoal()))
|
|
892
|
+
continue;
|
|
434
893
|
break;
|
|
435
894
|
}
|
|
895
|
+
const goalBefore = activeGoal(this.state);
|
|
436
896
|
const results = [];
|
|
437
897
|
for await (const event of this.executeCalls(calls, signal, results)) {
|
|
438
898
|
yield event;
|
|
439
899
|
}
|
|
900
|
+
// The goal tool settles the goal in place; saying so here is what lets
|
|
901
|
+
// a UI take down its "working toward" line the moment it is done.
|
|
902
|
+
if (goalBefore && goalBefore.status !== "active") {
|
|
903
|
+
yield { type: "goal", goal: { ...goalBefore } };
|
|
904
|
+
void this.session.persist().catch(() => { });
|
|
905
|
+
}
|
|
440
906
|
// All tool results for a turn must go back in one user message —
|
|
441
907
|
// splitting them teaches the model to stop calling tools in parallel.
|
|
442
908
|
await this.session.addMessage({
|
|
@@ -452,8 +918,10 @@ export class Agent {
|
|
|
452
918
|
// reaches here before it notices and would read a message it is about
|
|
453
919
|
// to stop answering, leaving it in the transcript with no reply. Left
|
|
454
920
|
// in the inbox it goes back to the composer instead.
|
|
455
|
-
if (!signal.aborted)
|
|
921
|
+
if (!signal.aborted) {
|
|
456
922
|
yield* this.takeInbox();
|
|
923
|
+
yield* this.takeNotices();
|
|
924
|
+
}
|
|
457
925
|
if (this.stopRequested) {
|
|
458
926
|
yield { type: "notice", message: this.stopRequested };
|
|
459
927
|
this.stopRequested = undefined;
|
|
@@ -465,10 +933,10 @@ export class Agent {
|
|
|
465
933
|
break;
|
|
466
934
|
}
|
|
467
935
|
}
|
|
468
|
-
if (turn >=
|
|
936
|
+
if (turn >= turnLimit() && stopReason === "end_turn") {
|
|
469
937
|
yield {
|
|
470
938
|
type: "notice",
|
|
471
|
-
message: `Stopped after ${
|
|
939
|
+
message: `Stopped after ${turn} turns without finishing. Send another message to continue.`,
|
|
472
940
|
};
|
|
473
941
|
}
|
|
474
942
|
await this.hooks.run({
|
|
@@ -491,6 +959,12 @@ export class Agent {
|
|
|
491
959
|
this.running = false;
|
|
492
960
|
await this.session.persist().catch(() => { });
|
|
493
961
|
finishRun?.();
|
|
962
|
+
// Results that ended after the loop's last look. Not after an interrupt:
|
|
963
|
+
// the person just said stop, and a turn starting by itself half a second
|
|
964
|
+
// later would be the agent ignoring them. They ride along with the next
|
|
965
|
+
// run instead, or wake one when another task ends.
|
|
966
|
+
if (stopReason !== "aborted" && this.background?.hasUndelivered())
|
|
967
|
+
this.scheduleWake();
|
|
494
968
|
}
|
|
495
969
|
yield {
|
|
496
970
|
type: "done",
|
|
@@ -501,6 +975,32 @@ export class Agent {
|
|
|
501
975
|
};
|
|
502
976
|
}
|
|
503
977
|
// --- Steps --------------------------------------------------------------
|
|
978
|
+
/**
|
|
979
|
+
* Where a reply ends with a goal still active: ask for more work.
|
|
980
|
+
*
|
|
981
|
+
* Returns whether the loop should go on. A model that keeps stopping short
|
|
982
|
+
* of its goal gets a bounded number of reminders, not an endless supply.
|
|
983
|
+
*/
|
|
984
|
+
async *pushTowardGoal() {
|
|
985
|
+
const goal = activeGoal(this.state);
|
|
986
|
+
if (!goal)
|
|
987
|
+
return false;
|
|
988
|
+
if (goal.nudges >= MAX_GOAL_NUDGES) {
|
|
989
|
+
yield {
|
|
990
|
+
type: "notice",
|
|
991
|
+
message: `Goal still open after ${MAX_GOAL_NUDGES} reminders; stopping here. Send a message to keep going.`,
|
|
992
|
+
};
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
goal.nudges++;
|
|
996
|
+
await this.session.addMessage({
|
|
997
|
+
role: "user",
|
|
998
|
+
content: [{ type: "text", text: goalNudge(goal) }],
|
|
999
|
+
meta: { timestamp: Date.now(), synthetic: true, goalNudge: true },
|
|
1000
|
+
});
|
|
1001
|
+
yield { type: "goal", goal: { ...goal } };
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
504
1004
|
async *ingestInput(input) {
|
|
505
1005
|
if (Array.isArray(input)) {
|
|
506
1006
|
for (const message of input)
|
|
@@ -675,11 +1175,75 @@ export class Agent {
|
|
|
675
1175
|
const length = this.session.messages.length;
|
|
676
1176
|
return Number.isSafeInteger(value) && value >= 0 ? Math.min(value, length) : length;
|
|
677
1177
|
}
|
|
678
|
-
|
|
679
|
-
|
|
1178
|
+
/**
|
|
1179
|
+
* How full the model's window is, in tokens: what the last request measured,
|
|
1180
|
+
* or, before one has (a new or reopened conversation), an estimate of the
|
|
1181
|
+
* next request — system prompt and tools included, which are a large share
|
|
1182
|
+
* of a short conversation.
|
|
1183
|
+
*/
|
|
1184
|
+
contextTokens() {
|
|
1185
|
+
return this.contextBreakdown().total;
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* The window, split the way the context panel draws it. Providers report one
|
|
1189
|
+
* number for the prompt, so the system prompt and the tool definitions are
|
|
1190
|
+
* estimated and the messages are what is left of the measured total; until
|
|
1191
|
+
* something is measured all three are estimates.
|
|
1192
|
+
*/
|
|
1193
|
+
contextBreakdown() {
|
|
1194
|
+
const window = this.session.model.contextWindow;
|
|
1195
|
+
// Kept per transcript: the terminal asks on every frame it draws, and the
|
|
1196
|
+
// prompt is rebuilt from skills, memory and plugins each time it is made.
|
|
1197
|
+
const transcript = this.session.messages;
|
|
1198
|
+
let estimate = this.contextEstimate;
|
|
1199
|
+
if (!estimate || estimate.messages !== transcript || estimate.length !== transcript.length) {
|
|
1200
|
+
const request = this.buildRequest(new AbortController().signal);
|
|
1201
|
+
estimate = {
|
|
1202
|
+
messages: transcript,
|
|
1203
|
+
length: transcript.length,
|
|
1204
|
+
system: estimateStringTokens(request.system ?? ""),
|
|
1205
|
+
tools: request.tools ? estimateToolsTokens(request.tools) : 0,
|
|
1206
|
+
transcript: estimateMessagesTokens(transcript),
|
|
1207
|
+
};
|
|
1208
|
+
this.contextEstimate = estimate;
|
|
1209
|
+
}
|
|
1210
|
+
const measured = this.session.measuredContextTokens();
|
|
1211
|
+
const fixed = estimate.system + estimate.tools;
|
|
1212
|
+
const total = measured ?? fixed + estimate.transcript;
|
|
1213
|
+
const compaction = this.opts.compaction;
|
|
1214
|
+
return {
|
|
1215
|
+
total,
|
|
1216
|
+
window,
|
|
1217
|
+
system: estimate.system,
|
|
1218
|
+
tools: estimate.tools,
|
|
1219
|
+
messages: Math.max(0, total - fixed),
|
|
1220
|
+
measured: measured !== undefined,
|
|
1221
|
+
// Where the automatic kind fires: a share of what is left once the
|
|
1222
|
+
// reply has been given room (see shouldCompact).
|
|
1223
|
+
...(compaction === false
|
|
1224
|
+
? {}
|
|
1225
|
+
: { compactAt: compactionPoint(this.session.model, compaction?.threshold) }),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
contextEstimate;
|
|
1229
|
+
/**
|
|
1230
|
+
* Compact now, whatever the window says: the /compact command. Goes through
|
|
1231
|
+
* the same path as the automatic kind, so what it drops is archived for
|
|
1232
|
+
* recall and handed to memory extraction rather than simply discarded.
|
|
1233
|
+
* Returns what happened, or undefined when there was nothing safe to cut.
|
|
1234
|
+
*/
|
|
1235
|
+
async compactNow() {
|
|
1236
|
+
let last;
|
|
1237
|
+
for await (const event of this.maybeCompact(true))
|
|
1238
|
+
last = event;
|
|
1239
|
+
return last;
|
|
1240
|
+
}
|
|
1241
|
+
async *maybeCompact(manual = false) {
|
|
1242
|
+
if (!manual && this.opts.compaction === false)
|
|
680
1243
|
return;
|
|
681
1244
|
const systemTokens = estimateStringTokens(this.systemPrompt());
|
|
682
|
-
|
|
1245
|
+
const options = this.opts.compaction || {};
|
|
1246
|
+
if (!manual && !shouldCompact(this.session.messages, this.session.model, systemTokens, options)) {
|
|
683
1247
|
return;
|
|
684
1248
|
}
|
|
685
1249
|
await this.hooks.run({
|
|
@@ -695,7 +1259,9 @@ export class Agent {
|
|
|
695
1259
|
// the session happens to be using.
|
|
696
1260
|
const summarizer = this.summarizerModel();
|
|
697
1261
|
const provider = this.session.providers.getProvider(summarizer.provider);
|
|
698
|
-
const result = await compact(this.session.messages, provider, summarizer,
|
|
1262
|
+
const result = await compact(this.session.messages, provider, summarizer,
|
|
1263
|
+
// Asked for by name, so keep less of the tail: the person wants room.
|
|
1264
|
+
manual ? { ...options, keepTailFraction: 0.15 } : options);
|
|
699
1265
|
if (result.droppedMessages === 0)
|
|
700
1266
|
return;
|
|
701
1267
|
// Read before the transcript changes shape: the marker is a position in it.
|
|
@@ -723,7 +1289,23 @@ export class Agent {
|
|
|
723
1289
|
yield { type: "notice", message: `Compaction failed: ${errorMessage(err)}` };
|
|
724
1290
|
}
|
|
725
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The tools offered to the model: the goal tool only while there is a goal,
|
|
1294
|
+
* and the computer tools only while this conversation has Computer Use on.
|
|
1295
|
+
*/
|
|
1296
|
+
visibleTools() {
|
|
1297
|
+
const goal = activeGoal(this.state);
|
|
1298
|
+
const computer = computerOn(this.state);
|
|
1299
|
+
const worktrees = Boolean(this.opts.worktreeLauncher?.());
|
|
1300
|
+
const background = this.backgroundAvailable();
|
|
1301
|
+
return this.tools
|
|
1302
|
+
.visible(this.opts.toolFilter)
|
|
1303
|
+
.filter((tool) => (goal || tool.name !== GOAL_TOOL) && (computer || !isComputerTool(tool.name)))
|
|
1304
|
+
.filter((tool) => worktrees || tool.name !== WORKTREE_START_TOOL)
|
|
1305
|
+
.filter((tool) => background || ![TASK_OUTPUT_TOOL, TASK_STOP_TOOL, TASK_MESSAGE_TOOL].includes(tool.name));
|
|
1306
|
+
}
|
|
726
1307
|
systemPrompt() {
|
|
1308
|
+
const goal = activeGoal(this.state);
|
|
727
1309
|
return buildSystemPrompt({
|
|
728
1310
|
systemPrompt: this.opts.systemPrompt,
|
|
729
1311
|
appendSystemPrompt: this.opts.appendSystemPrompt,
|
|
@@ -732,10 +1314,16 @@ export class Agent {
|
|
|
732
1314
|
permissionMode: this.session.permissions.getMode(),
|
|
733
1315
|
projectInstructions: this.opts.projectInstructions,
|
|
734
1316
|
mcpInstructions: this.opts.mcp?.serverInstructions(),
|
|
735
|
-
toolNames: this.
|
|
1317
|
+
toolNames: this.visibleTools().map((t) => t.name),
|
|
736
1318
|
// Only the descriptions — loading every skill body up front would
|
|
737
1319
|
// defeat the point of having them be loadable.
|
|
738
|
-
extraSections: [
|
|
1320
|
+
extraSections: [
|
|
1321
|
+
this.skills.promptSection(),
|
|
1322
|
+
this.memoryIndex,
|
|
1323
|
+
...this.pluginSections,
|
|
1324
|
+
...(goal ? [goalPromptSection(goal)] : []),
|
|
1325
|
+
...(computerOn(this.state) && this.tools.has("computer_list_apps") ? [computerPromptSection()] : []),
|
|
1326
|
+
].filter((section) => section !== undefined),
|
|
739
1327
|
});
|
|
740
1328
|
}
|
|
741
1329
|
buildRequest(signal) {
|
|
@@ -744,7 +1332,13 @@ export class Agent {
|
|
|
744
1332
|
model,
|
|
745
1333
|
system: this.systemPrompt(),
|
|
746
1334
|
messages: this.session.messages,
|
|
747
|
-
tools: model.supportsTools
|
|
1335
|
+
tools: model.supportsTools
|
|
1336
|
+
? this.visibleTools().map((tool) => ({
|
|
1337
|
+
name: tool.name,
|
|
1338
|
+
description: tool.description,
|
|
1339
|
+
inputSchema: tool.inputSchema,
|
|
1340
|
+
}))
|
|
1341
|
+
: undefined,
|
|
748
1342
|
toolChoice: { type: "auto" },
|
|
749
1343
|
thinking: thinkingFor(this.opts.thinking, this.session.effort),
|
|
750
1344
|
cache: this.opts.cache ?? true,
|
|
@@ -814,7 +1408,10 @@ export class Agent {
|
|
|
814
1408
|
// concurrently — one unsafe call forces the whole batch to serialize,
|
|
815
1409
|
// because we cannot know how they interact.
|
|
816
1410
|
const parallel = calls.length > 1 &&
|
|
817
|
-
calls.every((call) =>
|
|
1411
|
+
calls.every((call) => {
|
|
1412
|
+
const tool = this.tools.get(call.name);
|
|
1413
|
+
return tool?.concurrencySafe === true || tool?.concurrencySafeFor?.(call.input, this) === true;
|
|
1414
|
+
});
|
|
818
1415
|
if (!parallel) {
|
|
819
1416
|
for (const call of calls) {
|
|
820
1417
|
yield* this.executeOne(call, signal, results);
|
|
@@ -883,18 +1480,24 @@ export class Agent {
|
|
|
883
1480
|
this.stopRequested = reason;
|
|
884
1481
|
return;
|
|
885
1482
|
}
|
|
886
|
-
// The workspace policy runs before the approval prompt
|
|
887
|
-
//
|
|
1483
|
+
// The workspace policy runs before the approval prompt. A write outside
|
|
1484
|
+
// the roots is a question for the person, not a refusal: they are the one
|
|
1485
|
+
// who knows whether that folder is fair game, and the answer to it stands
|
|
1486
|
+
// in for the ordinary approval. A read-only workspace, plan mode, or no
|
|
1487
|
+
// one to ask still refuses outright.
|
|
1488
|
+
let approved = false;
|
|
888
1489
|
if (this.opts.workspace && !tool.readOnly) {
|
|
889
1490
|
const check = checkWrite(this.opts.workspace, call.name, input, this.session.cwd);
|
|
890
1491
|
if (!check.allowed) {
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1492
|
+
const outside = yield* this.askOutsideRoots(call, input, title, check, signal);
|
|
1493
|
+
if (!outside) {
|
|
1494
|
+
results.push(errorResult(call, check.reason ?? "blocked by the workspace policy"));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
approved = true;
|
|
895
1498
|
}
|
|
896
1499
|
}
|
|
897
|
-
const allowed = yield* this.checkPermission(call, tool, input, title, signal);
|
|
1500
|
+
const allowed = approved || (yield* this.checkPermission(call, tool, input, title, signal));
|
|
898
1501
|
if (!allowed)
|
|
899
1502
|
return void results.push(errorResult(call, permissionDenialText(title)));
|
|
900
1503
|
const ctx = {
|
|
@@ -1021,6 +1624,38 @@ export class Agent {
|
|
|
1021
1624
|
isError: result.isError,
|
|
1022
1625
|
});
|
|
1023
1626
|
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Ask about a write the workspace policy would refuse.
|
|
1629
|
+
*
|
|
1630
|
+
* Bypass mode approves it as it approves everything else. Read-only and plan
|
|
1631
|
+
* mode refuse it, as does a frontend with no way to ask.
|
|
1632
|
+
*/
|
|
1633
|
+
async *askOutsideRoots(call, input, title, check, signal) {
|
|
1634
|
+
const mode = this.session.permissions.getMode();
|
|
1635
|
+
const refuse = function* (reason) {
|
|
1636
|
+
yield { type: "permission_result", toolCallId: call.id, allowed: false, reason };
|
|
1637
|
+
return false;
|
|
1638
|
+
};
|
|
1639
|
+
if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt) {
|
|
1640
|
+
return yield* refuse(check.reason ?? "blocked by the workspace policy");
|
|
1641
|
+
}
|
|
1642
|
+
if (mode === "bypassPermissions")
|
|
1643
|
+
return true;
|
|
1644
|
+
const request = {
|
|
1645
|
+
toolName: call.name,
|
|
1646
|
+
input,
|
|
1647
|
+
title,
|
|
1648
|
+
reason: `Writes outside the project: ${check.offending ?? ""}`.trim(),
|
|
1649
|
+
...this.interactionPlace(),
|
|
1650
|
+
toolCallId: call.id,
|
|
1651
|
+
};
|
|
1652
|
+
yield { type: "permission_request", request };
|
|
1653
|
+
const prompt = this.opts.permissionPrompt;
|
|
1654
|
+
const answer = await untilAborted(signal, () => prompt(request, signal), false, false);
|
|
1655
|
+
const allowed = typeof answer === "boolean" ? answer : answer.allow;
|
|
1656
|
+
yield { type: "permission_result", toolCallId: call.id, allowed };
|
|
1657
|
+
return allowed;
|
|
1658
|
+
}
|
|
1024
1659
|
async *checkPermission(call, tool, input, title, signal) {
|
|
1025
1660
|
const decision = this.session.permissions.evaluate(tool, input);
|
|
1026
1661
|
if (decision.behavior === "allow")
|
|
@@ -1031,6 +1666,7 @@ export class Agent {
|
|
|
1031
1666
|
toolCallId: call.id,
|
|
1032
1667
|
allowed: false,
|
|
1033
1668
|
reason: decision.message,
|
|
1669
|
+
...(decision.rule === "plan" ? { plan: true } : {}),
|
|
1034
1670
|
};
|
|
1035
1671
|
return false;
|
|
1036
1672
|
}
|
|
@@ -1040,7 +1676,7 @@ export class Agent {
|
|
|
1040
1676
|
title,
|
|
1041
1677
|
reason: decision.rule ? `matched rule ${decision.rule}` : undefined,
|
|
1042
1678
|
...(decision.rule ? { rule: decision.rule } : {}),
|
|
1043
|
-
|
|
1679
|
+
...this.interactionPlace(),
|
|
1044
1680
|
toolCallId: call.id,
|
|
1045
1681
|
};
|
|
1046
1682
|
yield { type: "permission_request", request };
|