@oharness/engine 0.9.3 → 0.11.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/acp/claude.d.ts +34 -0
- package/dist/acp/claude.js +122 -0
- package/dist/acp/claude.js.map +1 -0
- package/dist/acp/client.d.ts +193 -0
- package/dist/acp/client.js +279 -0
- package/dist/acp/client.js.map +1 -0
- package/dist/acp/runner.d.ts +80 -0
- package/dist/acp/runner.js +159 -0
- package/dist/acp/runner.js.map +1 -0
- package/dist/agent/agent.d.ts +258 -2
- package/dist/agent/agent.js +1010 -53
- 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/hooks.js +6 -6
- package/dist/agent/hooks.js.map +1 -1
- 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 +38 -0
- package/dist/agent/session.js +91 -2
- 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 +21 -0
- package/dist/agent/subagent.js +17 -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/index.d.ts +3 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/manager.d.ts +9 -0
- package/dist/auth/manager.js +71 -6
- package/dist/auth/manager.js.map +1 -1
- package/dist/auth/oauth.d.ts +33 -0
- package/dist/auth/oauth.js +59 -8
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/providers/account.js +5 -4
- package/dist/auth/providers/account.js.map +1 -1
- package/dist/auth/providers/anthropic.d.ts +25 -5
- package/dist/auth/providers/anthropic.js +47 -6
- package/dist/auth/providers/anthropic.js.map +1 -1
- package/dist/auth/providers/chatgpt.d.ts +8 -0
- package/dist/auth/providers/chatgpt.js +180 -0
- package/dist/auth/providers/chatgpt.js.map +1 -0
- package/dist/auth/providers/openrouter.d.ts +2 -0
- package/dist/auth/providers/openrouter.js +101 -0
- package/dist/auth/providers/openrouter.js.map +1 -0
- package/dist/auth/types.d.ts +15 -0
- package/dist/auth/types.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/plugin.d.ts +6 -1
- package/dist/browser/plugin.js.map +1 -1
- package/dist/browser/session.d.ts +47 -0
- package/dist/browser/session.js +177 -2
- package/dist/browser/session.js.map +1 -1
- package/dist/browser/tools.js +142 -18
- 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 +174 -0
- package/dist/computer/cua-driver.js +597 -0
- package/dist/computer/cua-driver.js.map +1 -0
- package/dist/computer/helper-client.d.ts +80 -0
- package/dist/computer/helper-client.js +166 -0
- package/dist/computer/helper-client.js.map +1 -0
- package/dist/computer/index.d.ts +16 -0
- package/dist/computer/index.js +9 -0
- package/dist/computer/index.js.map +1 -0
- package/dist/computer/mode.d.ts +34 -0
- package/dist/computer/mode.js +52 -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 +228 -0
- package/dist/computer/session.js +493 -0
- package/dist/computer/session.js.map +1 -0
- package/dist/computer/target.d.ts +206 -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 +664 -0
- package/dist/computer/tools.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js.map +1 -1
- 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 +128 -1
- package/dist/harness.js +287 -30
- 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/manager.d.ts +15 -0
- package/dist/mcp/manager.js +28 -1
- package/dist/mcp/manager.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/catalog.json +14 -3
- package/dist/providers/discovery.js +10 -2
- package/dist/providers/discovery.js.map +1 -1
- package/dist/providers/models-dev.d.ts +15 -0
- package/dist/providers/models-dev.js +15 -0
- package/dist/providers/models-dev.js.map +1 -1
- package/dist/providers/models-dev.json +114 -102
- package/dist/providers/piai.d.ts +54 -2
- package/dist/providers/piai.js +154 -9
- package/dist/providers/piai.js.map +1 -1
- package/dist/providers/registry.d.ts +16 -1
- package/dist/providers/registry.js +42 -11
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/types.d.ts +6 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/runtime-plugins/builtin/studio.d.ts +7 -0
- package/dist/runtime-plugins/builtin/studio.js +69 -4
- package/dist/runtime-plugins/builtin/studio.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 +8 -11
- 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/util/shell.d.ts +30 -0
- package/dist/util/shell.js +70 -0
- package/dist/util/shell.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/skills/media-generation/SKILL.md +22 -1
package/dist/agent/agent.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GOAL_MAX_TURNS, GOAL_TOOL, MAX_GOAL_NUDGES, activeGoal, goalNudge, goalPromptSection, goalTool } from "./goal.js";
|
|
4
|
+
import { COMPUTER_ENABLE_TOOL, COMPUTER_KEY, computerOn, computerPromptSection, isComputerTool } from "../computer/mode.js";
|
|
1
5
|
import { randomUUID } from "node:crypto";
|
|
2
6
|
import { errorMessage } from "../core/errors.js";
|
|
3
|
-
import { estimateStringTokens } from "../core/tokens.js";
|
|
7
|
+
import { estimateMessagesTokens, estimateStringTokens, estimateToolsTokens } from "../core/tokens.js";
|
|
4
8
|
import { assistantMessage, emptyUsage, messageText, userMessage } from "../core/types.js";
|
|
5
9
|
import { thinkingFor } from "../providers/types.js";
|
|
10
|
+
import { WORKTREE_START_TOOL, worktreeStartTool } from "../tools/builtin/worktree.js";
|
|
11
|
+
import { BASH_CWD_KEY } from "../tools/builtin/bash.js";
|
|
12
|
+
import { createWorktree, finishWorktree, worktreeStatus } from "../worktree/index.js";
|
|
6
13
|
import { createTaskTool } from "../tools/builtin/task.js";
|
|
14
|
+
import { TASK_MESSAGE_TOOL, TASK_OUTPUT_TOOL, TASK_STOP_TOOL, taskMessageTool, taskOutputTool, taskStopTool, } from "../tools/builtin/task-control.js";
|
|
15
|
+
import { BackgroundTasks, formatNotification } from "./background.js";
|
|
7
16
|
import { ARCHIVE_READER } from "../tools/builtin/recall.js";
|
|
8
17
|
import { ARTIFACT_METADATA_KEY } from "../tools/builtin/artifact.js";
|
|
9
18
|
import { previewJson } from "../util/json.js";
|
|
10
19
|
import { AsyncQueue, mergeStreams } from "../util/queue.js";
|
|
11
20
|
import { createLogger } from "../util/logger.js";
|
|
12
|
-
import { compact, shouldCompact } from "./compaction.js";
|
|
21
|
+
import { compact, compactionPoint, shouldCompact } from "./compaction.js";
|
|
13
22
|
import { capToolResult } from "./overflow.js";
|
|
14
|
-
import { checkWrite, resolveWithin } from "./workspace.js";
|
|
23
|
+
import { checkWrite, resolveWithin, retargetPolicy } from "./workspace.js";
|
|
15
24
|
import { SkillRegistry } from "./skills.js";
|
|
16
25
|
import { MemoryStore, recallFromStores } from "./memory.js";
|
|
17
26
|
import { DEFAULT_MEMORY_POLICY } from "./memory-limits.js";
|
|
@@ -21,9 +30,21 @@ import { createSkillTool } from "../tools/builtin/skill.js";
|
|
|
21
30
|
import { createMcpResourceTools } from "../tools/builtin/mcp-resource.js";
|
|
22
31
|
import { HookRegistry } from "./hooks.js";
|
|
23
32
|
import { buildSystemPrompt } from "./prompt.js";
|
|
33
|
+
import { AcpSignInRequired, AcpUnavailable, runAcpTurn } from "../acp/runner.js";
|
|
24
34
|
import { ASK_USER_TOOL } from "../tools/questions.js";
|
|
25
35
|
import { SubagentRegistry } from "./subagent.js";
|
|
36
|
+
function withoutSignal(opts) {
|
|
37
|
+
const { signal: _signal, ...rest } = opts;
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
26
40
|
const DEFAULT_MAX_TURNS = 60;
|
|
41
|
+
/**
|
|
42
|
+
* How long an idle agent lets background results gather before asking for a
|
|
43
|
+
* turn to read them, so tasks ending together are answered together.
|
|
44
|
+
*/
|
|
45
|
+
const TASK_WAKE_DELAY_MS = 500;
|
|
46
|
+
/** How long task changes gather before they are written down. */
|
|
47
|
+
const TASK_SAVE_DELAY_MS = 1000;
|
|
27
48
|
/** Session state key: ids of notes attached since the last compaction. */
|
|
28
49
|
const RECALLED = "memory:recalled";
|
|
29
50
|
/** Session state key: how much of the transcript an end-of-run extraction has covered. */
|
|
@@ -57,14 +78,6 @@ function recalledIds(state, messages) {
|
|
|
57
78
|
}
|
|
58
79
|
return ids;
|
|
59
80
|
}
|
|
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
81
|
export class Agent {
|
|
69
82
|
session;
|
|
70
83
|
tools;
|
|
@@ -95,6 +108,18 @@ export class Agent {
|
|
|
95
108
|
pluginSections = [];
|
|
96
109
|
/** Recall over `memory` for agents embedded without a plugin host. */
|
|
97
110
|
legacyMemory;
|
|
111
|
+
/** Subagents running in the background; made on first use. */
|
|
112
|
+
background;
|
|
113
|
+
wakeTimer;
|
|
114
|
+
/** Frontends watching background tasks, kept until the tasks exist. */
|
|
115
|
+
changeListeners = new Set();
|
|
116
|
+
/** What each background task was asked, and its subagent once it has one. */
|
|
117
|
+
taskStates = new Map();
|
|
118
|
+
restoring;
|
|
119
|
+
saveTimer;
|
|
120
|
+
/** Closed or deleted: nothing more is saved, and nothing more starts. */
|
|
121
|
+
tasksClosed = false;
|
|
122
|
+
saveChain = Promise.resolve();
|
|
98
123
|
constructor(opts) {
|
|
99
124
|
this.opts = opts;
|
|
100
125
|
this.session = opts.session;
|
|
@@ -128,11 +153,25 @@ export class Agent {
|
|
|
128
153
|
if (!this.tools.has("skill") && this.skills.size > 0) {
|
|
129
154
|
this.tools.register(createSkillTool(this.skills));
|
|
130
155
|
}
|
|
156
|
+
if (!this.tools.has(GOAL_TOOL))
|
|
157
|
+
this.tools.register(goalTool);
|
|
158
|
+
// Shown only where a frontend can host the session it starts; see visibleTools.
|
|
159
|
+
if (!this.tools.has(WORKTREE_START_TOOL))
|
|
160
|
+
this.tools.register(worktreeStartTool);
|
|
131
161
|
// The task tool has to be built here: its description enumerates the
|
|
132
162
|
// subagent types that actually exist in this configuration.
|
|
133
163
|
if (!this.tools.has("task") && this.subagents.list().length > 0) {
|
|
134
164
|
this.tools.register(createTaskTool(this));
|
|
135
165
|
}
|
|
166
|
+
// Shown only where background tasks can be offered; see visibleTools.
|
|
167
|
+
if (this.tools.has("task")) {
|
|
168
|
+
if (!this.tools.has(TASK_OUTPUT_TOOL))
|
|
169
|
+
this.tools.register(taskOutputTool);
|
|
170
|
+
if (!this.tools.has(TASK_STOP_TOOL))
|
|
171
|
+
this.tools.register(taskStopTool);
|
|
172
|
+
if (!this.tools.has(TASK_MESSAGE_TOOL))
|
|
173
|
+
this.tools.register(taskMessageTool);
|
|
174
|
+
}
|
|
136
175
|
}
|
|
137
176
|
/**
|
|
138
177
|
* Announce a model switch so a UI can render it and hooks can react.
|
|
@@ -238,35 +277,389 @@ export class Agent {
|
|
|
238
277
|
resolvePath(input, access) {
|
|
239
278
|
return resolveWithin(this.opts.workspace, this.session.cwd, input, access);
|
|
240
279
|
}
|
|
280
|
+
async startWorktree(opts) {
|
|
281
|
+
const launch = this.opts.worktreeLauncher?.();
|
|
282
|
+
if (!launch)
|
|
283
|
+
throw new Error("this frontend cannot start a session in a worktree");
|
|
284
|
+
return launch({ ...opts, from: { cwd: this.session.cwd, sessionId: this.session.id } });
|
|
285
|
+
}
|
|
241
286
|
listSubagentTypes() {
|
|
242
287
|
return this.subagents.summaries();
|
|
243
288
|
}
|
|
244
289
|
async runSubagent(opts) {
|
|
290
|
+
const child = await this.prepareSubagent(opts);
|
|
291
|
+
try {
|
|
292
|
+
const result = await this.runChild(child, opts.prompt, opts.signal);
|
|
293
|
+
// Propagate state the subagent built up (e.g. files it read). Not from a
|
|
294
|
+
// worktree: what it read there are paths in another tree.
|
|
295
|
+
if (!child.worktree)
|
|
296
|
+
for (const [key, value] of child.agent.session.state)
|
|
297
|
+
this.session.state.set(key, value);
|
|
298
|
+
// A worktree it left changes in is named in the result: the parent has
|
|
299
|
+
// to decide what happens to that work.
|
|
300
|
+
const kept = await this.worktreeNote(child);
|
|
301
|
+
const text = kept
|
|
302
|
+
? `${result.text}\n\n[worktree] ${kept} It is kept: review it, then merge, push or apply it, or discard it from the sidebar.`
|
|
303
|
+
: result.text;
|
|
304
|
+
return { text, turns: result.turns, usage: result.usage };
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await this.disposeChild(child);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// --- Background tasks ----------------------------------------------------
|
|
311
|
+
/**
|
|
312
|
+
* Subagents running behind this conversation, offered only while someone
|
|
313
|
+
* can start a turn to read their results in. Once any exist they stay
|
|
314
|
+
* reachable, so they can still be listed, messaged and stopped.
|
|
315
|
+
*/
|
|
316
|
+
get backgroundTasks() {
|
|
317
|
+
if (!this.backgroundAvailable())
|
|
318
|
+
return undefined;
|
|
319
|
+
// Read without making the table: every open conversation is asked for
|
|
320
|
+
// its list, and most never start a task.
|
|
321
|
+
return {
|
|
322
|
+
start: (opts) => {
|
|
323
|
+
const state = { opts: withoutSignal(opts) };
|
|
324
|
+
const view = this.tasks().start(this.backgroundSpec(state));
|
|
325
|
+
this.taskStates.set(view.id, state);
|
|
326
|
+
return view;
|
|
327
|
+
},
|
|
328
|
+
get: (id) => this.background?.get(id),
|
|
329
|
+
list: () => this.background?.list() ?? [],
|
|
330
|
+
stop: (id) => this.background?.stop(id) ?? false,
|
|
331
|
+
send: (id, message) => this.background?.send(id, message) ?? "unknown",
|
|
332
|
+
dismiss: (id) => this.background?.dismiss(id) ?? false,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** A task's conversation so far, for a frontend to show. */
|
|
336
|
+
backgroundTranscript(id) {
|
|
337
|
+
return this.background?.transcript(id);
|
|
338
|
+
}
|
|
339
|
+
/** Every change to a background task, for a frontend to redraw from. */
|
|
340
|
+
onBackgroundChange(listener) {
|
|
341
|
+
this.changeListeners.add(listener);
|
|
342
|
+
const off = this.background?.onChange(listener);
|
|
343
|
+
return () => {
|
|
344
|
+
this.changeListeners.delete(listener);
|
|
345
|
+
off?.();
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The conversation is being closed, not ended: write its tasks down as they
|
|
350
|
+
* stand, then stop them. Opened again, the ones that were running come back
|
|
351
|
+
* interrupted and can be followed up — as after a restart.
|
|
352
|
+
*/
|
|
353
|
+
async closeBackgroundTasks() {
|
|
354
|
+
if (this.tasksClosed)
|
|
355
|
+
return;
|
|
356
|
+
if (this.saveTimer)
|
|
357
|
+
clearTimeout(this.saveTimer);
|
|
358
|
+
this.saveTimer = undefined;
|
|
359
|
+
await this.saveTasks();
|
|
360
|
+
this.endBackgroundTasks();
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The conversation is being deleted: stop its tasks and write nothing more.
|
|
364
|
+
* Resolves once a write already under way has landed, so deleting the
|
|
365
|
+
* store afterwards cannot be undone by it.
|
|
366
|
+
*/
|
|
367
|
+
async discardBackgroundTasks() {
|
|
368
|
+
this.endBackgroundTasks();
|
|
369
|
+
await this.saveChain;
|
|
370
|
+
}
|
|
371
|
+
endBackgroundTasks() {
|
|
372
|
+
this.tasksClosed = true;
|
|
373
|
+
if (this.wakeTimer)
|
|
374
|
+
clearTimeout(this.wakeTimer);
|
|
375
|
+
if (this.saveTimer)
|
|
376
|
+
clearTimeout(this.saveTimer);
|
|
377
|
+
this.wakeTimer = undefined;
|
|
378
|
+
this.saveTimer = undefined;
|
|
379
|
+
this.background?.stopAll();
|
|
380
|
+
this.background?.detach();
|
|
381
|
+
}
|
|
382
|
+
backgroundAvailable() {
|
|
383
|
+
return Boolean(this.background) || Boolean(this.opts.taskWaker?.());
|
|
384
|
+
}
|
|
385
|
+
tasks() {
|
|
386
|
+
if (!this.background) {
|
|
387
|
+
this.background = new BackgroundTasks({
|
|
388
|
+
...(this.opts.maxBackgroundTasks ? { maxConcurrent: this.opts.maxBackgroundTasks } : {}),
|
|
389
|
+
...(this.opts.lifecycleSignal ? { signal: this.opts.lifecycleSignal } : {}),
|
|
390
|
+
});
|
|
391
|
+
// While a run is going its own loop reads results in at the next gap;
|
|
392
|
+
// only an idle agent needs someone to start a turn for them. And an
|
|
393
|
+
// ended task is written down at once rather than after the usual pause:
|
|
394
|
+
// its result is the one thing a restart must not lose.
|
|
395
|
+
this.background.onSettled(() => {
|
|
396
|
+
if (!this.running)
|
|
397
|
+
this.scheduleWake();
|
|
398
|
+
void this.saveTasks();
|
|
399
|
+
});
|
|
400
|
+
for (const listener of this.changeListeners)
|
|
401
|
+
this.background.onChange(listener);
|
|
402
|
+
this.background.onChange(() => this.scheduleSave());
|
|
403
|
+
}
|
|
404
|
+
return this.background;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A background task's subagent, made for each run and let go after it.
|
|
408
|
+
*
|
|
409
|
+
* Between runs a task holds only its conversation and, if it changed
|
|
410
|
+
* anything, its worktree: no browser, no claim on the computer, no agent.
|
|
411
|
+
* A follow-up — or the first one after a restart — builds the subagent
|
|
412
|
+
* again from those, so both paths are the same path.
|
|
413
|
+
*/
|
|
414
|
+
backgroundSpec(state) {
|
|
415
|
+
const once = async (input, signal, report) => {
|
|
416
|
+
const child = await this.prepareSubagent(state.opts, {
|
|
417
|
+
messages: state.messages ?? [],
|
|
418
|
+
...(state.worktree ? { worktree: state.worktree } : {}),
|
|
419
|
+
...(state.equipment ? { equipment: state.equipment } : {}),
|
|
420
|
+
});
|
|
421
|
+
// Its tree is gone — removed by hand, or after a restart — and it now
|
|
422
|
+
// works in a new one: said up front, since its conversation names the
|
|
423
|
+
// old paths.
|
|
424
|
+
const moved = state.worktree && child.worktree && state.worktree.path !== child.worktree.path
|
|
425
|
+
? `[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`
|
|
426
|
+
: "";
|
|
427
|
+
state.child = child;
|
|
428
|
+
state.equipment = child.equipment;
|
|
429
|
+
if (child.worktree)
|
|
430
|
+
state.worktree = child.worktree;
|
|
431
|
+
try {
|
|
432
|
+
const result = await this.runChild(child, moved + input, signal, report);
|
|
433
|
+
const kept = await this.worktreeNote(child);
|
|
434
|
+
return { ...result, ...(kept ? { worktree: kept } : {}) };
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
// The agent goes; its conversation, tree and browser stay with the
|
|
438
|
+
// task until it is stopped, or its conversation closed.
|
|
439
|
+
state.messages = [...child.agent.session.messages];
|
|
440
|
+
state.child = undefined;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
return {
|
|
444
|
+
agentType: state.opts.agentType,
|
|
445
|
+
description: state.opts.description || state.opts.agentType,
|
|
446
|
+
run: (signal, report) => once(state.opts.prompt, signal, report),
|
|
447
|
+
// A task that failed before its subagent ever ran has no conversation to
|
|
448
|
+
// continue; the follow-up then goes with the task it was given.
|
|
449
|
+
resume: (message, signal, report) => once(state.messages?.length ? message : `${state.opts.prompt}\n\n${message}`, signal, report),
|
|
450
|
+
interject: (message) => Boolean(state.child?.agent.interject(message)),
|
|
451
|
+
transcript: () => state.child?.agent.session.messages ?? state.messages ?? [],
|
|
452
|
+
dispose: async () => {
|
|
453
|
+
await state.equipment?.release().catch(() => { });
|
|
454
|
+
state.equipment = undefined;
|
|
455
|
+
if (state.worktree)
|
|
456
|
+
await this.dropIfUntouched(state.worktree);
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Put back the background tasks this conversation had before a restart.
|
|
462
|
+
*
|
|
463
|
+
* Called by a frontend when it opens the conversation; safe to call again.
|
|
464
|
+
* A task that was still running when the app closed comes back as failed
|
|
465
|
+
* and says why — its subagent's process is gone — but keeps its
|
|
466
|
+
* conversation, so a follow-up carries on from where it stopped. A result
|
|
467
|
+
* nobody had been told about is still read in with the next turn.
|
|
468
|
+
*/
|
|
469
|
+
async restoreBackgroundTasks() {
|
|
470
|
+
if (this.restoring)
|
|
471
|
+
return this.restoring;
|
|
472
|
+
this.restoring = (async () => {
|
|
473
|
+
if (this.tasksClosed)
|
|
474
|
+
return 0;
|
|
475
|
+
const saved = await this.session.loadTasks().catch(() => []);
|
|
476
|
+
// Closed while it was reading: bringing them back now would make a
|
|
477
|
+
// table nothing will ever close.
|
|
478
|
+
if (this.tasksClosed)
|
|
479
|
+
return 0;
|
|
480
|
+
let restored = 0;
|
|
481
|
+
for (const raw of saved) {
|
|
482
|
+
const item = raw;
|
|
483
|
+
if (!item?.view?.id || !item.options?.agentType || !this.subagents.get(item.options.agentType))
|
|
484
|
+
continue;
|
|
485
|
+
const view = { ...item.view };
|
|
486
|
+
let delivered = item.delivered === true;
|
|
487
|
+
if (view.status === "running" || view.status === "queued") {
|
|
488
|
+
view.status = "failed";
|
|
489
|
+
view.error = "Interrupted: OHarness closed while this task was running. Send it a message to carry on from where it stopped.";
|
|
490
|
+
view.endedAt = Date.now();
|
|
491
|
+
delete view.activity;
|
|
492
|
+
delivered = true;
|
|
493
|
+
}
|
|
494
|
+
const state = {
|
|
495
|
+
opts: item.options,
|
|
496
|
+
messages: Array.isArray(item.messages) ? item.messages : [],
|
|
497
|
+
...(item.worktree ? { worktree: item.worktree } : {}),
|
|
498
|
+
};
|
|
499
|
+
this.taskStates.set(view.id, state);
|
|
500
|
+
this.tasks().restore(view, this.backgroundSpec(state), delivered);
|
|
501
|
+
restored++;
|
|
502
|
+
}
|
|
503
|
+
return restored;
|
|
504
|
+
})();
|
|
505
|
+
return this.restoring;
|
|
506
|
+
}
|
|
507
|
+
/** Write the tasks down a moment after they change, so a burst is one write. */
|
|
508
|
+
scheduleSave() {
|
|
509
|
+
if (this.saveTimer || this.tasksClosed)
|
|
510
|
+
return;
|
|
511
|
+
this.saveTimer = setTimeout(() => {
|
|
512
|
+
this.saveTimer = undefined;
|
|
513
|
+
void this.saveTasks();
|
|
514
|
+
}, TASK_SAVE_DELAY_MS);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* One write at a time, in order. Each takes its snapshot when its turn
|
|
518
|
+
* comes, so a later state is never overwritten by an earlier one landing
|
|
519
|
+
* last — a task that ended would come back as running.
|
|
520
|
+
*/
|
|
521
|
+
saveTasks() {
|
|
522
|
+
const next = this.saveChain.then(() => this.writeTasks());
|
|
523
|
+
this.saveChain = next.catch(() => { });
|
|
524
|
+
return next;
|
|
525
|
+
}
|
|
526
|
+
async writeTasks() {
|
|
527
|
+
// Shutting down stops every task; writing that down would bring them back
|
|
528
|
+
// as stopped for good, when they were only interrupted. The last save from
|
|
529
|
+
// while they ran is the truer record. A closed conversation has already
|
|
530
|
+
// written its own.
|
|
531
|
+
if (!this.background || this.tasksClosed || this.opts.lifecycleSignal?.aborted)
|
|
532
|
+
return;
|
|
533
|
+
const tasks = this.background.saved().flatMap(({ view, delivered }) => {
|
|
534
|
+
const state = this.taskStates.get(view.id);
|
|
535
|
+
if (!state)
|
|
536
|
+
return [];
|
|
537
|
+
const worktree = state.child?.worktree ?? state.worktree;
|
|
538
|
+
return [{
|
|
539
|
+
view,
|
|
540
|
+
delivered,
|
|
541
|
+
options: state.opts,
|
|
542
|
+
messages: [...(state.child?.agent.session.messages ?? state.messages ?? [])],
|
|
543
|
+
...(worktree ? { worktree } : {}),
|
|
544
|
+
}];
|
|
545
|
+
});
|
|
546
|
+
await this.session.saveTasks(tasks).catch((err) => {
|
|
547
|
+
this.logger.warn("could not save background tasks", { error: errorMessage(err) });
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
scheduleWake() {
|
|
551
|
+
if (this.wakeTimer || this.tasksClosed)
|
|
552
|
+
return;
|
|
553
|
+
this.wakeTimer = setTimeout(() => {
|
|
554
|
+
this.wakeTimer = undefined;
|
|
555
|
+
if (this.running || !this.background?.hasUndelivered())
|
|
556
|
+
return;
|
|
557
|
+
this.opts.taskWaker?.()?.(this.session.id);
|
|
558
|
+
}, TASK_WAKE_DELAY_MS);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Read the results of background tasks that have ended into the
|
|
562
|
+
* conversation, as one message saying what is still running.
|
|
563
|
+
*
|
|
564
|
+
* Synthetic, like a goal nudge: nobody typed it, so it skips the prompt hook
|
|
565
|
+
* and memory recall, and never titles the session.
|
|
566
|
+
*/
|
|
567
|
+
async *takeNotices() {
|
|
568
|
+
const ended = this.background?.takeUndelivered() ?? [];
|
|
569
|
+
if (!ended.length)
|
|
570
|
+
return false;
|
|
571
|
+
const text = formatNotification(ended, this.background?.active() ?? []);
|
|
572
|
+
await this.session.addMessage({
|
|
573
|
+
role: "user",
|
|
574
|
+
content: [{ type: "text", text }],
|
|
575
|
+
// What a transcript needs to draw it as a card rather than as text
|
|
576
|
+
// somebody typed: which tasks, and how each ended.
|
|
577
|
+
meta: {
|
|
578
|
+
timestamp: Date.now(),
|
|
579
|
+
synthetic: true,
|
|
580
|
+
taskNotification: ended.map((task) => ({ id: task.id, description: task.description, status: task.status })),
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
yield { type: "task_notification", tasks: ended, text };
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
/** Where this agent's permission cards go, and who they say is asking. */
|
|
587
|
+
interactionPlace() {
|
|
588
|
+
return {
|
|
589
|
+
sessionId: this.opts.interactionSessionId ?? this.session.id,
|
|
590
|
+
...(this.opts.interactionLabel ? { from: this.opts.interactionLabel } : {}),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
// --- Subagents -----------------------------------------------------------
|
|
594
|
+
/** A subagent ready to run: its agent, the tree it works in, what it holds. */
|
|
595
|
+
async prepareSubagent(opts, seed) {
|
|
245
596
|
const definition = this.subagents.get(opts.agentType);
|
|
246
597
|
if (!definition)
|
|
247
598
|
throw new Error(`unknown agent type: ${opts.agentType}`);
|
|
248
599
|
// A subagent gets its own transcript and a filtered tool set, but shares
|
|
249
600
|
// the parent's scratch state so file-read tracking stays coherent.
|
|
250
601
|
const { Session: SessionClass } = await import("./session.js");
|
|
602
|
+
// Its own tree when asked for, or when the agent type always wants one:
|
|
603
|
+
// several editing subagents in one checkout would trip over each other.
|
|
604
|
+
// Branched from this checkout's HEAD rather than the remote, so it starts
|
|
605
|
+
// from the commits the parent is looking at.
|
|
606
|
+
const isolated = opts.isolation === "worktree" || definition.isolation === "worktree";
|
|
607
|
+
// A restored task goes back to its own tree while that still exists.
|
|
608
|
+
const kept = seed?.worktree && (await worktreeStatus(seed.worktree).catch(() => undefined))?.exists ? seed.worktree : undefined;
|
|
609
|
+
const worktree = kept ?? (isolated
|
|
610
|
+
? await createWorktree(this.session.cwd, {
|
|
611
|
+
task: opts.description || definition.name,
|
|
612
|
+
slug: opts.description ? undefined : `${definition.name}-agent`,
|
|
613
|
+
base: "HEAD",
|
|
614
|
+
fetch: false,
|
|
615
|
+
})
|
|
616
|
+
: undefined);
|
|
617
|
+
const childCwd = worktree?.cwd ?? this.session.cwd;
|
|
251
618
|
const child = new SessionClass({
|
|
252
|
-
cwd:
|
|
619
|
+
cwd: childCwd,
|
|
253
620
|
model: opts.model ?? definition.model ?? this.session.model.id,
|
|
254
621
|
providers: this.session.providers,
|
|
255
622
|
permissionMode: this.session.permissions.getMode(),
|
|
256
623
|
store: false,
|
|
624
|
+
// Through the constructor rather than added one by one: it drops a
|
|
625
|
+
// tail a run was cut off in — a tool call with no result — which no
|
|
626
|
+
// provider accepts a following message after.
|
|
627
|
+
...(seed?.messages.length ? { messages: [...seed.messages] } : {}),
|
|
257
628
|
});
|
|
258
629
|
for (const [key, value] of this.session.state)
|
|
259
630
|
child.state.set(key, value);
|
|
631
|
+
// A shell that had cd'd somewhere in the parent's tree must start in its own.
|
|
632
|
+
if (worktree)
|
|
633
|
+
child.state.delete(BASH_CWD_KEY);
|
|
634
|
+
// Control of the screen is never inherited by default: a subagent works
|
|
635
|
+
// unattended, and drives the computer only when asked to and only while
|
|
636
|
+
// the conversation that asked has it switched on.
|
|
637
|
+
if (!(opts.computer && computerOn(this.session.state)))
|
|
638
|
+
child.state.delete(COMPUTER_KEY);
|
|
260
639
|
child.permissions.onDecision((entry) => {
|
|
261
640
|
this.audit({ type: "permission", timestamp: Date.now(), sessionId: child.id, entry });
|
|
262
641
|
});
|
|
263
642
|
// A subagent does not ask the person questions. Its events never reach the
|
|
264
643
|
// parent's stream, so a card it raised would have no call to belong to —
|
|
265
644
|
// and needing to ask usually means the delegation was underspecified,
|
|
266
|
-
// which is the parent's to resolve.
|
|
645
|
+
// which is the parent's to resolve. Nor does it see the parent's
|
|
646
|
+
// background tasks: it has none of its own.
|
|
267
647
|
const childTools = definition.tools ? this.tools.filtered(definition.tools) : this.tools.clone();
|
|
268
648
|
childTools.unregister(ASK_USER_TOOL);
|
|
269
|
-
|
|
649
|
+
// Nor may it switch the screen on for itself; see the COMPUTER_KEY rule above.
|
|
650
|
+
childTools.unregister(COMPUTER_ENABLE_TOOL);
|
|
651
|
+
childTools.unregister(TASK_OUTPUT_TOOL);
|
|
652
|
+
childTools.unregister(TASK_STOP_TOOL);
|
|
653
|
+
childTools.unregister(TASK_MESSAGE_TOOL);
|
|
654
|
+
const label = opts.description ? `${definition.name}: ${opts.description}` : definition.name;
|
|
655
|
+
// Its own browser tabs and its own place in the computer's leases, so two
|
|
656
|
+
// subagents — or a subagent and its parent — never act on one page.
|
|
657
|
+
let equipment = seed?.equipment;
|
|
658
|
+
if (equipment?.bind)
|
|
659
|
+
equipment.bind(childTools);
|
|
660
|
+
else
|
|
661
|
+
equipment = this.opts.equipSubagent?.(child.id, label, childTools);
|
|
662
|
+
const agent = new Agent({
|
|
270
663
|
...this.opts,
|
|
271
664
|
questionPrompt: undefined,
|
|
272
665
|
session: child,
|
|
@@ -275,41 +668,372 @@ export class Agent {
|
|
|
275
668
|
appendSystemPrompt: undefined,
|
|
276
669
|
maxTurns: definition.maxTurns ?? this.opts.maxTurns,
|
|
277
670
|
agentId: `${this.agentId}/${definition.name}`,
|
|
671
|
+
...(worktree && this.opts.workspace
|
|
672
|
+
? { workspace: retargetPolicy(this.opts.workspace, this.session.cwd, worktree.cwd) }
|
|
673
|
+
: {}),
|
|
674
|
+
// A subagent hands work back to its parent; it does not start sessions
|
|
675
|
+
// or background tasks.
|
|
676
|
+
worktreeLauncher: undefined,
|
|
677
|
+
taskWaker: undefined,
|
|
678
|
+
equipSubagent: undefined,
|
|
679
|
+
...(equipment?.onRunStart ? { onRunStart: equipment.onRunStart } : {}),
|
|
680
|
+
interactionSessionId: this.opts.interactionSessionId ?? this.session.id,
|
|
681
|
+
interactionLabel: label,
|
|
278
682
|
// One level of delegation: a subagent may not spawn further subagents.
|
|
279
683
|
subagents: new SubagentRegistry([]),
|
|
280
684
|
// The parent already attached what matched the user's message, and a
|
|
281
685
|
// subagent's prompt is the parent's wording rather than the user's.
|
|
282
686
|
memoryProvider: undefined,
|
|
283
687
|
});
|
|
688
|
+
return { agent, definition, ...(worktree ? { worktree } : {}), ...(equipment ? { equipment } : {}) };
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* One run of a subagent, to its end: its first prompt, or a later message.
|
|
692
|
+
*
|
|
693
|
+
* `report` is there only for a background task: it is what lets a look at
|
|
694
|
+
* the task say what the subagent is doing now.
|
|
695
|
+
*/
|
|
696
|
+
async runChild(child, input, signal, report) {
|
|
697
|
+
if (child.definition.runtime?.kind === "acp")
|
|
698
|
+
return this.runExternalChild(child, input, signal, report);
|
|
284
699
|
let text = "";
|
|
285
700
|
let turns = 0;
|
|
286
701
|
let usage = emptyUsage();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
702
|
+
// Said to it after its last look at its inbox would otherwise sit there
|
|
703
|
+
// until some later run, out of context: answered now, as one more run.
|
|
704
|
+
for (let next = input; next !== undefined;) {
|
|
705
|
+
for await (const event of child.agent.run(next, { ...(signal ? { signal } : {}) })) {
|
|
706
|
+
if (event.type === "assistant_message") {
|
|
707
|
+
const messageBody = messageText(event.message);
|
|
708
|
+
if (messageBody.trim())
|
|
709
|
+
text = messageBody;
|
|
710
|
+
}
|
|
711
|
+
else if (event.type === "turn_start") {
|
|
712
|
+
report?.turn();
|
|
713
|
+
}
|
|
714
|
+
else if (event.type === "tool_start") {
|
|
715
|
+
report?.activity(event.title);
|
|
716
|
+
}
|
|
717
|
+
else if (event.type === "done") {
|
|
718
|
+
turns += event.turns;
|
|
719
|
+
usage = event.usage;
|
|
720
|
+
}
|
|
296
721
|
}
|
|
722
|
+
const unread = signal?.aborted ? [] : child.agent.takeUnread();
|
|
723
|
+
next = unread.length ? unread.map((entry) => entry.text).join("\n\n") : undefined;
|
|
297
724
|
}
|
|
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
725
|
await this.hooks.run({
|
|
302
726
|
event: "SubagentStop",
|
|
303
727
|
sessionId: this.session.id,
|
|
304
|
-
agentId: `${this.agentId}/${definition.name}`,
|
|
728
|
+
agentId: `${this.agentId}/${child.definition.name}`,
|
|
729
|
+
cwd: this.session.cwd,
|
|
730
|
+
});
|
|
731
|
+
return { text, turns, usage: { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens } };
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* One run of a subagent whose loop is an external agent's (ACP).
|
|
735
|
+
*
|
|
736
|
+
* The child session is still kept, as the record of the task: the prompt
|
|
737
|
+
* and the agent's answer go into it, so a background task's transcript and
|
|
738
|
+
* a follow-up after a restart read the same as any other subagent's. The
|
|
739
|
+
* agent itself starts fresh each run, so what came before rides in the
|
|
740
|
+
* prompt.
|
|
741
|
+
*/
|
|
742
|
+
async runExternalChild(child, input, signal, report) {
|
|
743
|
+
const runtime = child.definition.runtime;
|
|
744
|
+
const session = child.agent.session;
|
|
745
|
+
const earlier = session.messages
|
|
746
|
+
.map((message) => `${message.role === "user" ? "Asked" : "You answered"}: ${messageText(message).trim()}`)
|
|
747
|
+
.filter((line) => !line.endsWith(": "));
|
|
748
|
+
const prompt = earlier.length
|
|
749
|
+
? `Earlier in this task:\n\n${earlier.join("\n\n")}\n\nNow: ${input}`
|
|
750
|
+
: input;
|
|
751
|
+
// The agent's own session from the last run, when it ran in this folder:
|
|
752
|
+
// a follow-up resumes it, so the agent keeps what it learned, and only
|
|
753
|
+
// the new message is sent. Another folder (a replaced worktree) is not
|
|
754
|
+
// the same session, whatever its id.
|
|
755
|
+
const last = [...session.messages].reverse().find((message) => message.role === "assistant");
|
|
756
|
+
const previous = last?.meta?.["acpSession"];
|
|
757
|
+
const resume = typeof previous?.id === "string" && previous.cwd === session.cwd
|
|
758
|
+
? { sessionId: previous.id, prompt: input }
|
|
759
|
+
: undefined;
|
|
760
|
+
const servers = await this.forwardedMcpServers();
|
|
761
|
+
await session.addMessage({ role: "user", content: [{ type: "text", text: input }], meta: { timestamp: Date.now() } });
|
|
762
|
+
report?.turn();
|
|
763
|
+
let text;
|
|
764
|
+
let acpSession;
|
|
765
|
+
try {
|
|
766
|
+
const result = await runAcpTurn({
|
|
767
|
+
runtime,
|
|
768
|
+
cwd: session.cwd,
|
|
769
|
+
prompt,
|
|
770
|
+
...(resume ? { resume } : {}),
|
|
771
|
+
...(servers.length ? { mcpServers: servers } : {}),
|
|
772
|
+
// Its file reads and writes through the same policy as ours: in the
|
|
773
|
+
// project (or its worktree), and nowhere it was not allowed to go.
|
|
774
|
+
fs: child.agent.externalFs(),
|
|
775
|
+
...(signal ? { signal } : {}),
|
|
776
|
+
askPermission: (title, toolInput, toolCallId) => child.agent.askForExternal(title, toolInput, toolCallId, signal),
|
|
777
|
+
onActivity: (title) => report?.activity(title),
|
|
778
|
+
});
|
|
779
|
+
text = result.text || (result.stopReason === "cancelled" ? "Stopped." : "The agent finished without saying anything.");
|
|
780
|
+
acpSession = { id: result.sessionId, cwd: session.cwd };
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
// Said as the answer rather than thrown: "sign in to Claude Code" is
|
|
784
|
+
// what the parent has to pass on, not a crash to report.
|
|
785
|
+
if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable)
|
|
786
|
+
text = err.message;
|
|
787
|
+
else
|
|
788
|
+
throw err;
|
|
789
|
+
}
|
|
790
|
+
await session.addMessage({
|
|
791
|
+
role: "assistant",
|
|
792
|
+
content: [{ type: "text", text }],
|
|
793
|
+
meta: { timestamp: Date.now(), ...(acpSession ? { acpSession } : {}) },
|
|
794
|
+
});
|
|
795
|
+
await this.hooks.run({
|
|
796
|
+
event: "SubagentStop",
|
|
797
|
+
sessionId: this.session.id,
|
|
798
|
+
agentId: `${this.agentId}/${child.definition.name}`,
|
|
305
799
|
cwd: this.session.cwd,
|
|
306
800
|
});
|
|
801
|
+
// No usage: the external agent bills its own account and reports none.
|
|
802
|
+
return { text, turns: 1, usage: { inputTokens: 0, outputTokens: 0 } };
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* File access for an external agent, through this agent's workspace policy:
|
|
806
|
+
* reads where ours may read, writes where ours may write, nowhere else.
|
|
807
|
+
*/
|
|
808
|
+
externalFs() {
|
|
307
809
|
return {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
810
|
+
read: async (file, line, limit) => {
|
|
811
|
+
const text = await readFile(this.resolvePath(file, "read"), "utf8");
|
|
812
|
+
if (line === undefined && limit === undefined)
|
|
813
|
+
return text;
|
|
814
|
+
const lines = text.split("\n");
|
|
815
|
+
const start = Math.max(0, (line ?? 1) - 1);
|
|
816
|
+
return lines.slice(start, limit === undefined ? undefined : start + limit).join("\n");
|
|
817
|
+
},
|
|
818
|
+
write: async (file, content) => {
|
|
819
|
+
const target = this.resolvePath(file, "write");
|
|
820
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
821
|
+
await writeFile(target, content);
|
|
822
|
+
},
|
|
311
823
|
};
|
|
312
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* `@claude …`: a message addressed to an external agent by name.
|
|
827
|
+
*
|
|
828
|
+
* Only an agent with an ACP runtime answers to this, and only at the start
|
|
829
|
+
* of what was typed — or of the paragraph after a reference note, which the
|
|
830
|
+
* page puts above the prompt. The note stays with the message; the name
|
|
831
|
+
* does not.
|
|
832
|
+
*/
|
|
833
|
+
directTarget(input) {
|
|
834
|
+
if (typeof input !== "string")
|
|
835
|
+
return undefined;
|
|
836
|
+
const match = /(^|\n\n)@([\w./-]+)[ \t]+(\S[\s\S]*)$/.exec(input);
|
|
837
|
+
if (!match)
|
|
838
|
+
return undefined;
|
|
839
|
+
const definition = this.subagents.get(match[2]);
|
|
840
|
+
if (definition?.runtime?.kind !== "acp")
|
|
841
|
+
return undefined;
|
|
842
|
+
const note = input.slice(0, match.index).trim();
|
|
843
|
+
return { definition, text: note ? `${note}\n\n${match[3]}` : match[3] };
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* A turn answered by an external agent instead of the model.
|
|
847
|
+
*
|
|
848
|
+
* What makes `@claude` worth having: the message goes straight to the
|
|
849
|
+
* user's own Claude Code, so it is paid for by their Claude account and
|
|
850
|
+
* nothing is spent on this harness's model in between. The agent's reply
|
|
851
|
+
* streams into the conversation as text, its tool calls as tool cards, its
|
|
852
|
+
* questions as the usual permission card; the exchange is kept as an
|
|
853
|
+
* ordinary user/assistant pair, and the agent's session id rides on the
|
|
854
|
+
* reply so the next `@claude` picks up where this one left off.
|
|
855
|
+
*/
|
|
856
|
+
async *runDirect(target, raw, signal) {
|
|
857
|
+
const { definition, text } = target;
|
|
858
|
+
const runtime = definition.runtime;
|
|
859
|
+
const finishRun = this.opts.onRunStart?.();
|
|
860
|
+
let stopReason = "end_turn";
|
|
861
|
+
try {
|
|
862
|
+
await this.session.init();
|
|
863
|
+
await this.session.addMessage(userMessage(raw));
|
|
864
|
+
yield { type: "turn_start", turn: 1, model: definition.name };
|
|
865
|
+
const previous = [...this.session.messages]
|
|
866
|
+
.reverse()
|
|
867
|
+
.find((message) => message.role === "assistant" && message.meta?.["agent"] === definition.name)
|
|
868
|
+
?.meta?.["acpSession"];
|
|
869
|
+
const resume = typeof previous?.id === "string" && previous.cwd === this.session.cwd
|
|
870
|
+
? { sessionId: previous.id, prompt: text }
|
|
871
|
+
: undefined;
|
|
872
|
+
const events = channel();
|
|
873
|
+
const started = new Map();
|
|
874
|
+
let reply = "";
|
|
875
|
+
const servers = await this.forwardedMcpServers();
|
|
876
|
+
const turn = runAcpTurn({
|
|
877
|
+
runtime,
|
|
878
|
+
cwd: this.session.cwd,
|
|
879
|
+
prompt: text,
|
|
880
|
+
...(resume ? { resume } : {}),
|
|
881
|
+
...(servers.length ? { mcpServers: servers } : {}),
|
|
882
|
+
fs: this.externalFs(),
|
|
883
|
+
signal,
|
|
884
|
+
askPermission: (title, input, toolCallId) => this.askForExternal(title, input, toolCallId, signal, definition.name),
|
|
885
|
+
onActivity: (title) => {
|
|
886
|
+
if (title === "Starting the agent…")
|
|
887
|
+
events.push({ type: "notice", message: `Starting ${definition.name}…` });
|
|
888
|
+
},
|
|
889
|
+
onUpdate: (update) => {
|
|
890
|
+
if (update.sessionUpdate === "agent_message_chunk" || update.sessionUpdate === "agent_thought_chunk") {
|
|
891
|
+
const content = update.content;
|
|
892
|
+
if (content?.type !== "text" || !content.text)
|
|
893
|
+
return;
|
|
894
|
+
if (update.sessionUpdate === "agent_thought_chunk")
|
|
895
|
+
return events.push({ type: "thinking_delta", text: content.text });
|
|
896
|
+
reply += content.text;
|
|
897
|
+
events.push({ type: "text_delta", text: content.text });
|
|
898
|
+
}
|
|
899
|
+
else if (update.sessionUpdate === "tool_call") {
|
|
900
|
+
const call = update;
|
|
901
|
+
const id = `${definition.name}:${call.toolCallId}`;
|
|
902
|
+
started.set(id, Date.now());
|
|
903
|
+
events.push({
|
|
904
|
+
type: "tool_start",
|
|
905
|
+
toolCallId: id,
|
|
906
|
+
toolName: call.kind || "tool",
|
|
907
|
+
title: call.title || call.kind || "tool",
|
|
908
|
+
input: call.rawInput ?? {},
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
else if (update.sessionUpdate === "tool_call_update") {
|
|
912
|
+
const call = update;
|
|
913
|
+
const id = `${definition.name}:${call.toolCallId}`;
|
|
914
|
+
if ((call.status !== "completed" && call.status !== "failed") || !started.has(id))
|
|
915
|
+
return;
|
|
916
|
+
const began = started.get(id);
|
|
917
|
+
started.delete(id);
|
|
918
|
+
events.push({
|
|
919
|
+
type: "tool_end",
|
|
920
|
+
toolCallId: id,
|
|
921
|
+
toolName: call.kind || "tool",
|
|
922
|
+
result: { content: [{ type: "text", text: call.status }], ...(call.status === "failed" ? { isError: true } : {}) },
|
|
923
|
+
durationMs: Date.now() - began,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
void turn.then(() => events.close(), () => events.close());
|
|
929
|
+
for await (const event of events.drain())
|
|
930
|
+
yield event;
|
|
931
|
+
let acpSession;
|
|
932
|
+
try {
|
|
933
|
+
const result = await turn;
|
|
934
|
+
acpSession = { id: result.sessionId, cwd: this.session.cwd };
|
|
935
|
+
if (result.stopReason === "cancelled" || signal.aborted)
|
|
936
|
+
stopReason = "aborted";
|
|
937
|
+
if (!reply.trim())
|
|
938
|
+
reply = result.text;
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
if (err instanceof AcpSignInRequired || err instanceof AcpUnavailable) {
|
|
942
|
+
reply = err.message;
|
|
943
|
+
yield { type: "text_delta", text: err.message };
|
|
944
|
+
}
|
|
945
|
+
else if (signal.aborted) {
|
|
946
|
+
stopReason = "aborted";
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
stopReason = "error";
|
|
950
|
+
yield { type: "error", error: `${definition.name}: ${errorMessage(err)}`, fatal: false };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (reply.trim()) {
|
|
954
|
+
const message = {
|
|
955
|
+
role: "assistant",
|
|
956
|
+
content: [{ type: "text", text: reply }],
|
|
957
|
+
meta: { timestamp: Date.now(), agent: definition.name, ...(acpSession ? { acpSession } : {}) },
|
|
958
|
+
};
|
|
959
|
+
await this.session.addMessage(message);
|
|
960
|
+
yield { type: "assistant_message", message };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
finally {
|
|
964
|
+
this.running = false;
|
|
965
|
+
await this.session.persist().catch(() => { });
|
|
966
|
+
finishRun?.();
|
|
967
|
+
}
|
|
968
|
+
yield { type: "done", stopReason, usage: this.session.usage, costUsd: this.session.costUsd, turns: 1 };
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* This conversation's MCP servers, in the shape ACP's `session/new` takes,
|
|
972
|
+
* so an external agent works with the same tools. HTTP ones are filtered
|
|
973
|
+
* out later for an agent that does not take them.
|
|
974
|
+
*/
|
|
975
|
+
async forwardedMcpServers() {
|
|
976
|
+
const pairs = (values) => Object.entries(values ?? {}).map(([name, value]) => ({ name, value }));
|
|
977
|
+
const found = await this.opts.mcp?.forwardable().catch(() => []) ?? [];
|
|
978
|
+
return found.map(({ name, config, headers }) => config.type === "stdio"
|
|
979
|
+
? { name, command: config.command, args: config.args ?? [], env: pairs(config.env) }
|
|
980
|
+
: { type: "http", name, url: config.url, headers: pairs({ ...config.headers, ...headers }) });
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* An external agent's own tool call, put to the person the way this
|
|
984
|
+
* harness's calls are — the same card, placed in the conversation that
|
|
985
|
+
* started the task and labelled with who is asking. Plan mode and a
|
|
986
|
+
* read-only workspace refuse; bypass allows, as it would for our own tools.
|
|
987
|
+
*/
|
|
988
|
+
async askForExternal(title, input, toolCallId, signal, from) {
|
|
989
|
+
const mode = this.session.permissions.getMode();
|
|
990
|
+
if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt)
|
|
991
|
+
return false;
|
|
992
|
+
if (mode === "bypassPermissions")
|
|
993
|
+
return true;
|
|
994
|
+
const request = {
|
|
995
|
+
toolName: "external",
|
|
996
|
+
input,
|
|
997
|
+
title,
|
|
998
|
+
...this.interactionPlace(),
|
|
999
|
+
...(from ? { from } : {}),
|
|
1000
|
+
toolCallId,
|
|
1001
|
+
};
|
|
1002
|
+
const prompt = this.opts.permissionPrompt;
|
|
1003
|
+
const ask = () => prompt(request, signal);
|
|
1004
|
+
return signal ? untilAborted(signal, ask, false, false) : ask();
|
|
1005
|
+
}
|
|
1006
|
+
/** What a subagent left in its worktree, when it left anything. */
|
|
1007
|
+
async worktreeNote(child) {
|
|
1008
|
+
if (!child.worktree)
|
|
1009
|
+
return undefined;
|
|
1010
|
+
const status = await worktreeStatus(child.worktree).catch(() => undefined);
|
|
1011
|
+
if (status?.clean)
|
|
1012
|
+
return undefined;
|
|
1013
|
+
const what = status
|
|
1014
|
+
? [status.commits ? `${status.commits} commit(s)` : "", status.changes.length ? `${status.changes.length} changed file(s)` : ""]
|
|
1015
|
+
.filter(Boolean)
|
|
1016
|
+
.join(", ")
|
|
1017
|
+
: "changes";
|
|
1018
|
+
const worktree = child.worktree;
|
|
1019
|
+
const where = worktree.branch ? `${worktree.path} on branch ${worktree.branch}` : `${worktree.path} (a copy of the folder)`;
|
|
1020
|
+
return `The subagent worked in ${where} and left ${what} there.`;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* A subagent is finished with: its browser and computer go, and a worktree
|
|
1024
|
+
* it changed nothing in is removed. One that it changed is kept — the
|
|
1025
|
+
* parent has to decide what happens to that work.
|
|
1026
|
+
*/
|
|
1027
|
+
async disposeChild(child) {
|
|
1028
|
+
await child.equipment?.release().catch(() => { });
|
|
1029
|
+
if (child.worktree)
|
|
1030
|
+
await this.dropIfUntouched(child.worktree);
|
|
1031
|
+
}
|
|
1032
|
+
async dropIfUntouched(worktree) {
|
|
1033
|
+
const status = await worktreeStatus(worktree).catch(() => undefined);
|
|
1034
|
+
if (status?.clean)
|
|
1035
|
+
await finishWorktree(worktree, "remove").catch(() => { });
|
|
1036
|
+
}
|
|
313
1037
|
// --- Main loop ----------------------------------------------------------
|
|
314
1038
|
async *run(input, runOpts = {}) {
|
|
315
1039
|
this.running = true;
|
|
@@ -323,6 +1047,12 @@ export class Agent {
|
|
|
323
1047
|
this.running = false;
|
|
324
1048
|
throw new Error("Agent host is closed");
|
|
325
1049
|
}
|
|
1050
|
+
// Addressed to an external agent by name: its turn, not the model's.
|
|
1051
|
+
const direct = this.directTarget(input);
|
|
1052
|
+
if (direct) {
|
|
1053
|
+
yield* this.runDirect(direct, input, signal);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
326
1056
|
// Fired once per session rather than per turn: hooks use it to seed
|
|
327
1057
|
// context or announce the session, which should not repeat every prompt.
|
|
328
1058
|
if (!this.sessionStarted) {
|
|
@@ -338,7 +1068,10 @@ export class Agent {
|
|
|
338
1068
|
yield { type: "notice", message: start.additionalContext };
|
|
339
1069
|
}
|
|
340
1070
|
}
|
|
341
|
-
const
|
|
1071
|
+
const baseMaxTurns = this.opts.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
1072
|
+
// Read per turn rather than once: a goal can be reached, or reported
|
|
1073
|
+
// blocked, partway through the run, and the ordinary ceiling returns then.
|
|
1074
|
+
const turnLimit = () => (activeGoal(this.state) ? Math.max(baseMaxTurns, GOAL_MAX_TURNS) : baseMaxTurns);
|
|
342
1075
|
let stopReason = "end_turn";
|
|
343
1076
|
let turn = 0;
|
|
344
1077
|
const finishRun = this.opts.onRunStart?.();
|
|
@@ -359,7 +1092,13 @@ export class Agent {
|
|
|
359
1092
|
yield { type: "memory_extracted", notes: extracted };
|
|
360
1093
|
}
|
|
361
1094
|
yield* this.ingestInput(input);
|
|
362
|
-
|
|
1095
|
+
// Background results that ended since the last run ride along with
|
|
1096
|
+
// whatever started this one. A run started only to read them in is
|
|
1097
|
+
// given no input of its own, and has nothing to answer if another path
|
|
1098
|
+
// got to them first.
|
|
1099
|
+
const noticed = yield* this.takeNotices();
|
|
1100
|
+
const nothingToAnswer = Array.isArray(input) && input.length === 0 && !noticed;
|
|
1101
|
+
while (!nothingToAnswer && turn < turnLimit()) {
|
|
363
1102
|
if (signal.aborted) {
|
|
364
1103
|
stopReason = "aborted";
|
|
365
1104
|
break;
|
|
@@ -428,15 +1167,28 @@ export class Agent {
|
|
|
428
1167
|
// rather than ending and starting a new run keeps one turn's worth
|
|
429
1168
|
// of context, and matches what the user saw: they typed it before
|
|
430
1169
|
// the reply landed.
|
|
431
|
-
if (!signal.aborted
|
|
432
|
-
|
|
1170
|
+
if (!signal.aborted) {
|
|
1171
|
+
const said = yield* this.takeInbox();
|
|
1172
|
+
const noticed = yield* this.takeNotices();
|
|
1173
|
+
if (said || noticed)
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
433
1176
|
stopReason = assembled.stopReason === "tool_use" ? "end_turn" : assembled.stopReason;
|
|
1177
|
+
if (!signal.aborted && (yield* this.pushTowardGoal()))
|
|
1178
|
+
continue;
|
|
434
1179
|
break;
|
|
435
1180
|
}
|
|
1181
|
+
const goalBefore = activeGoal(this.state);
|
|
436
1182
|
const results = [];
|
|
437
1183
|
for await (const event of this.executeCalls(calls, signal, results)) {
|
|
438
1184
|
yield event;
|
|
439
1185
|
}
|
|
1186
|
+
// The goal tool settles the goal in place; saying so here is what lets
|
|
1187
|
+
// a UI take down its "working toward" line the moment it is done.
|
|
1188
|
+
if (goalBefore && goalBefore.status !== "active") {
|
|
1189
|
+
yield { type: "goal", goal: { ...goalBefore } };
|
|
1190
|
+
void this.session.persist().catch(() => { });
|
|
1191
|
+
}
|
|
440
1192
|
// All tool results for a turn must go back in one user message —
|
|
441
1193
|
// splitting them teaches the model to stop calling tools in parallel.
|
|
442
1194
|
await this.session.addMessage({
|
|
@@ -452,8 +1204,10 @@ export class Agent {
|
|
|
452
1204
|
// reaches here before it notices and would read a message it is about
|
|
453
1205
|
// to stop answering, leaving it in the transcript with no reply. Left
|
|
454
1206
|
// in the inbox it goes back to the composer instead.
|
|
455
|
-
if (!signal.aborted)
|
|
1207
|
+
if (!signal.aborted) {
|
|
456
1208
|
yield* this.takeInbox();
|
|
1209
|
+
yield* this.takeNotices();
|
|
1210
|
+
}
|
|
457
1211
|
if (this.stopRequested) {
|
|
458
1212
|
yield { type: "notice", message: this.stopRequested };
|
|
459
1213
|
this.stopRequested = undefined;
|
|
@@ -465,10 +1219,10 @@ export class Agent {
|
|
|
465
1219
|
break;
|
|
466
1220
|
}
|
|
467
1221
|
}
|
|
468
|
-
if (turn >=
|
|
1222
|
+
if (turn >= turnLimit() && stopReason === "end_turn") {
|
|
469
1223
|
yield {
|
|
470
1224
|
type: "notice",
|
|
471
|
-
message: `Stopped after ${
|
|
1225
|
+
message: `Stopped after ${turn} turns without finishing. Send another message to continue.`,
|
|
472
1226
|
};
|
|
473
1227
|
}
|
|
474
1228
|
await this.hooks.run({
|
|
@@ -491,6 +1245,12 @@ export class Agent {
|
|
|
491
1245
|
this.running = false;
|
|
492
1246
|
await this.session.persist().catch(() => { });
|
|
493
1247
|
finishRun?.();
|
|
1248
|
+
// Results that ended after the loop's last look. Not after an interrupt:
|
|
1249
|
+
// the person just said stop, and a turn starting by itself half a second
|
|
1250
|
+
// later would be the agent ignoring them. They ride along with the next
|
|
1251
|
+
// run instead, or wake one when another task ends.
|
|
1252
|
+
if (stopReason !== "aborted" && this.background?.hasUndelivered())
|
|
1253
|
+
this.scheduleWake();
|
|
494
1254
|
}
|
|
495
1255
|
yield {
|
|
496
1256
|
type: "done",
|
|
@@ -501,6 +1261,32 @@ export class Agent {
|
|
|
501
1261
|
};
|
|
502
1262
|
}
|
|
503
1263
|
// --- Steps --------------------------------------------------------------
|
|
1264
|
+
/**
|
|
1265
|
+
* Where a reply ends with a goal still active: ask for more work.
|
|
1266
|
+
*
|
|
1267
|
+
* Returns whether the loop should go on. A model that keeps stopping short
|
|
1268
|
+
* of its goal gets a bounded number of reminders, not an endless supply.
|
|
1269
|
+
*/
|
|
1270
|
+
async *pushTowardGoal() {
|
|
1271
|
+
const goal = activeGoal(this.state);
|
|
1272
|
+
if (!goal)
|
|
1273
|
+
return false;
|
|
1274
|
+
if (goal.nudges >= MAX_GOAL_NUDGES) {
|
|
1275
|
+
yield {
|
|
1276
|
+
type: "notice",
|
|
1277
|
+
message: `Goal still open after ${MAX_GOAL_NUDGES} reminders; stopping here. Send a message to keep going.`,
|
|
1278
|
+
};
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
goal.nudges++;
|
|
1282
|
+
await this.session.addMessage({
|
|
1283
|
+
role: "user",
|
|
1284
|
+
content: [{ type: "text", text: goalNudge(goal) }],
|
|
1285
|
+
meta: { timestamp: Date.now(), synthetic: true, goalNudge: true },
|
|
1286
|
+
});
|
|
1287
|
+
yield { type: "goal", goal: { ...goal } };
|
|
1288
|
+
return true;
|
|
1289
|
+
}
|
|
504
1290
|
async *ingestInput(input) {
|
|
505
1291
|
if (Array.isArray(input)) {
|
|
506
1292
|
for (const message of input)
|
|
@@ -675,11 +1461,75 @@ export class Agent {
|
|
|
675
1461
|
const length = this.session.messages.length;
|
|
676
1462
|
return Number.isSafeInteger(value) && value >= 0 ? Math.min(value, length) : length;
|
|
677
1463
|
}
|
|
678
|
-
|
|
679
|
-
|
|
1464
|
+
/**
|
|
1465
|
+
* How full the model's window is, in tokens: what the last request measured,
|
|
1466
|
+
* or, before one has (a new or reopened conversation), an estimate of the
|
|
1467
|
+
* next request — system prompt and tools included, which are a large share
|
|
1468
|
+
* of a short conversation.
|
|
1469
|
+
*/
|
|
1470
|
+
contextTokens() {
|
|
1471
|
+
return this.contextBreakdown().total;
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* The window, split the way the context panel draws it. Providers report one
|
|
1475
|
+
* number for the prompt, so the system prompt and the tool definitions are
|
|
1476
|
+
* estimated and the messages are what is left of the measured total; until
|
|
1477
|
+
* something is measured all three are estimates.
|
|
1478
|
+
*/
|
|
1479
|
+
contextBreakdown() {
|
|
1480
|
+
const window = this.session.model.contextWindow;
|
|
1481
|
+
// Kept per transcript: the terminal asks on every frame it draws, and the
|
|
1482
|
+
// prompt is rebuilt from skills, memory and plugins each time it is made.
|
|
1483
|
+
const transcript = this.session.messages;
|
|
1484
|
+
let estimate = this.contextEstimate;
|
|
1485
|
+
if (!estimate || estimate.messages !== transcript || estimate.length !== transcript.length) {
|
|
1486
|
+
const request = this.buildRequest(new AbortController().signal);
|
|
1487
|
+
estimate = {
|
|
1488
|
+
messages: transcript,
|
|
1489
|
+
length: transcript.length,
|
|
1490
|
+
system: estimateStringTokens(request.system ?? ""),
|
|
1491
|
+
tools: request.tools ? estimateToolsTokens(request.tools) : 0,
|
|
1492
|
+
transcript: estimateMessagesTokens(transcript),
|
|
1493
|
+
};
|
|
1494
|
+
this.contextEstimate = estimate;
|
|
1495
|
+
}
|
|
1496
|
+
const measured = this.session.measuredContextTokens();
|
|
1497
|
+
const fixed = estimate.system + estimate.tools;
|
|
1498
|
+
const total = measured ?? fixed + estimate.transcript;
|
|
1499
|
+
const compaction = this.opts.compaction;
|
|
1500
|
+
return {
|
|
1501
|
+
total,
|
|
1502
|
+
window,
|
|
1503
|
+
system: estimate.system,
|
|
1504
|
+
tools: estimate.tools,
|
|
1505
|
+
messages: Math.max(0, total - fixed),
|
|
1506
|
+
measured: measured !== undefined,
|
|
1507
|
+
// Where the automatic kind fires: a share of what is left once the
|
|
1508
|
+
// reply has been given room (see shouldCompact).
|
|
1509
|
+
...(compaction === false
|
|
1510
|
+
? {}
|
|
1511
|
+
: { compactAt: compactionPoint(this.session.model, compaction?.threshold) }),
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
contextEstimate;
|
|
1515
|
+
/**
|
|
1516
|
+
* Compact now, whatever the window says: the /compact command. Goes through
|
|
1517
|
+
* the same path as the automatic kind, so what it drops is archived for
|
|
1518
|
+
* recall and handed to memory extraction rather than simply discarded.
|
|
1519
|
+
* Returns what happened, or undefined when there was nothing safe to cut.
|
|
1520
|
+
*/
|
|
1521
|
+
async compactNow() {
|
|
1522
|
+
let last;
|
|
1523
|
+
for await (const event of this.maybeCompact(true))
|
|
1524
|
+
last = event;
|
|
1525
|
+
return last;
|
|
1526
|
+
}
|
|
1527
|
+
async *maybeCompact(manual = false) {
|
|
1528
|
+
if (!manual && this.opts.compaction === false)
|
|
680
1529
|
return;
|
|
681
1530
|
const systemTokens = estimateStringTokens(this.systemPrompt());
|
|
682
|
-
|
|
1531
|
+
const options = this.opts.compaction || {};
|
|
1532
|
+
if (!manual && !shouldCompact(this.session.messages, this.session.model, systemTokens, options)) {
|
|
683
1533
|
return;
|
|
684
1534
|
}
|
|
685
1535
|
await this.hooks.run({
|
|
@@ -695,7 +1545,9 @@ export class Agent {
|
|
|
695
1545
|
// the session happens to be using.
|
|
696
1546
|
const summarizer = this.summarizerModel();
|
|
697
1547
|
const provider = this.session.providers.getProvider(summarizer.provider);
|
|
698
|
-
const result = await compact(this.session.messages, provider, summarizer,
|
|
1548
|
+
const result = await compact(this.session.messages, provider, summarizer,
|
|
1549
|
+
// Asked for by name, so keep less of the tail: the person wants room.
|
|
1550
|
+
manual ? { ...options, keepTailFraction: 0.15 } : options);
|
|
699
1551
|
if (result.droppedMessages === 0)
|
|
700
1552
|
return;
|
|
701
1553
|
// Read before the transcript changes shape: the marker is a position in it.
|
|
@@ -723,7 +1575,26 @@ export class Agent {
|
|
|
723
1575
|
yield { type: "notice", message: `Compaction failed: ${errorMessage(err)}` };
|
|
724
1576
|
}
|
|
725
1577
|
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The tools offered to the model: the goal tool only while there is a goal,
|
|
1580
|
+
* and the computer tools only while this conversation has Computer Use on.
|
|
1581
|
+
*/
|
|
1582
|
+
visibleTools() {
|
|
1583
|
+
const goal = activeGoal(this.state);
|
|
1584
|
+
const computer = computerOn(this.state);
|
|
1585
|
+
const worktrees = Boolean(this.opts.worktreeLauncher?.());
|
|
1586
|
+
const background = this.backgroundAvailable();
|
|
1587
|
+
return this.tools
|
|
1588
|
+
.visible(this.opts.toolFilter)
|
|
1589
|
+
.filter((tool) => (goal || tool.name !== GOAL_TOOL) && (computer || !isComputerTool(tool.name)))
|
|
1590
|
+
// Asking for the computer is for a conversation without it and with
|
|
1591
|
+
// somebody there: a routine or a headless run has nobody to answer.
|
|
1592
|
+
.filter((tool) => tool.name !== COMPUTER_ENABLE_TOOL || (!computer && Boolean(this.opts.permissionPrompt)))
|
|
1593
|
+
.filter((tool) => worktrees || tool.name !== WORKTREE_START_TOOL)
|
|
1594
|
+
.filter((tool) => background || ![TASK_OUTPUT_TOOL, TASK_STOP_TOOL, TASK_MESSAGE_TOOL].includes(tool.name));
|
|
1595
|
+
}
|
|
726
1596
|
systemPrompt() {
|
|
1597
|
+
const goal = activeGoal(this.state);
|
|
727
1598
|
return buildSystemPrompt({
|
|
728
1599
|
systemPrompt: this.opts.systemPrompt,
|
|
729
1600
|
appendSystemPrompt: this.opts.appendSystemPrompt,
|
|
@@ -732,10 +1603,16 @@ export class Agent {
|
|
|
732
1603
|
permissionMode: this.session.permissions.getMode(),
|
|
733
1604
|
projectInstructions: this.opts.projectInstructions,
|
|
734
1605
|
mcpInstructions: this.opts.mcp?.serverInstructions(),
|
|
735
|
-
toolNames: this.
|
|
1606
|
+
toolNames: this.visibleTools().map((t) => t.name),
|
|
736
1607
|
// Only the descriptions — loading every skill body up front would
|
|
737
1608
|
// defeat the point of having them be loadable.
|
|
738
|
-
extraSections: [
|
|
1609
|
+
extraSections: [
|
|
1610
|
+
this.skills.promptSection(),
|
|
1611
|
+
this.memoryIndex,
|
|
1612
|
+
...this.pluginSections,
|
|
1613
|
+
...(goal ? [goalPromptSection(goal)] : []),
|
|
1614
|
+
...(computerOn(this.state) && this.tools.has("computer_list_apps") ? [computerPromptSection()] : []),
|
|
1615
|
+
].filter((section) => section !== undefined),
|
|
739
1616
|
});
|
|
740
1617
|
}
|
|
741
1618
|
buildRequest(signal) {
|
|
@@ -744,7 +1621,13 @@ export class Agent {
|
|
|
744
1621
|
model,
|
|
745
1622
|
system: this.systemPrompt(),
|
|
746
1623
|
messages: this.session.messages,
|
|
747
|
-
tools: model.supportsTools
|
|
1624
|
+
tools: model.supportsTools
|
|
1625
|
+
? this.visibleTools().map((tool) => ({
|
|
1626
|
+
name: tool.name,
|
|
1627
|
+
description: tool.description,
|
|
1628
|
+
inputSchema: tool.inputSchema,
|
|
1629
|
+
}))
|
|
1630
|
+
: undefined,
|
|
748
1631
|
toolChoice: { type: "auto" },
|
|
749
1632
|
thinking: thinkingFor(this.opts.thinking, this.session.effort),
|
|
750
1633
|
cache: this.opts.cache ?? true,
|
|
@@ -814,7 +1697,10 @@ export class Agent {
|
|
|
814
1697
|
// concurrently — one unsafe call forces the whole batch to serialize,
|
|
815
1698
|
// because we cannot know how they interact.
|
|
816
1699
|
const parallel = calls.length > 1 &&
|
|
817
|
-
calls.every((call) =>
|
|
1700
|
+
calls.every((call) => {
|
|
1701
|
+
const tool = this.tools.get(call.name);
|
|
1702
|
+
return tool?.concurrencySafe === true || tool?.concurrencySafeFor?.(call.input, this) === true;
|
|
1703
|
+
});
|
|
818
1704
|
if (!parallel) {
|
|
819
1705
|
for (const call of calls) {
|
|
820
1706
|
yield* this.executeOne(call, signal, results);
|
|
@@ -883,18 +1769,24 @@ export class Agent {
|
|
|
883
1769
|
this.stopRequested = reason;
|
|
884
1770
|
return;
|
|
885
1771
|
}
|
|
886
|
-
// The workspace policy runs before the approval prompt
|
|
887
|
-
//
|
|
1772
|
+
// The workspace policy runs before the approval prompt. A write outside
|
|
1773
|
+
// the roots is a question for the person, not a refusal: they are the one
|
|
1774
|
+
// who knows whether that folder is fair game, and the answer to it stands
|
|
1775
|
+
// in for the ordinary approval. A read-only workspace, plan mode, or no
|
|
1776
|
+
// one to ask still refuses outright.
|
|
1777
|
+
let approved = false;
|
|
888
1778
|
if (this.opts.workspace && !tool.readOnly) {
|
|
889
1779
|
const check = checkWrite(this.opts.workspace, call.name, input, this.session.cwd);
|
|
890
1780
|
if (!check.allowed) {
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1781
|
+
const outside = yield* this.askOutsideRoots(call, input, title, check, signal);
|
|
1782
|
+
if (!outside) {
|
|
1783
|
+
results.push(errorResult(call, check.reason ?? "blocked by the workspace policy"));
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
approved = true;
|
|
895
1787
|
}
|
|
896
1788
|
}
|
|
897
|
-
const allowed = yield* this.checkPermission(call, tool, input, title, signal);
|
|
1789
|
+
const allowed = approved || (yield* this.checkPermission(call, tool, input, title, signal));
|
|
898
1790
|
if (!allowed)
|
|
899
1791
|
return void results.push(errorResult(call, permissionDenialText(title)));
|
|
900
1792
|
const ctx = {
|
|
@@ -1021,6 +1913,38 @@ export class Agent {
|
|
|
1021
1913
|
isError: result.isError,
|
|
1022
1914
|
});
|
|
1023
1915
|
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Ask about a write the workspace policy would refuse.
|
|
1918
|
+
*
|
|
1919
|
+
* Bypass mode approves it as it approves everything else. Read-only and plan
|
|
1920
|
+
* mode refuse it, as does a frontend with no way to ask.
|
|
1921
|
+
*/
|
|
1922
|
+
async *askOutsideRoots(call, input, title, check, signal) {
|
|
1923
|
+
const mode = this.session.permissions.getMode();
|
|
1924
|
+
const refuse = function* (reason) {
|
|
1925
|
+
yield { type: "permission_result", toolCallId: call.id, allowed: false, reason };
|
|
1926
|
+
return false;
|
|
1927
|
+
};
|
|
1928
|
+
if (this.opts.workspace?.mode === "read-only" || mode === "plan" || !this.opts.permissionPrompt) {
|
|
1929
|
+
return yield* refuse(check.reason ?? "blocked by the workspace policy");
|
|
1930
|
+
}
|
|
1931
|
+
if (mode === "bypassPermissions")
|
|
1932
|
+
return true;
|
|
1933
|
+
const request = {
|
|
1934
|
+
toolName: call.name,
|
|
1935
|
+
input,
|
|
1936
|
+
title,
|
|
1937
|
+
reason: `Writes outside the project: ${check.offending ?? ""}`.trim(),
|
|
1938
|
+
...this.interactionPlace(),
|
|
1939
|
+
toolCallId: call.id,
|
|
1940
|
+
};
|
|
1941
|
+
yield { type: "permission_request", request };
|
|
1942
|
+
const prompt = this.opts.permissionPrompt;
|
|
1943
|
+
const answer = await untilAborted(signal, () => prompt(request, signal), false, false);
|
|
1944
|
+
const allowed = typeof answer === "boolean" ? answer : answer.allow;
|
|
1945
|
+
yield { type: "permission_result", toolCallId: call.id, allowed };
|
|
1946
|
+
return allowed;
|
|
1947
|
+
}
|
|
1024
1948
|
async *checkPermission(call, tool, input, title, signal) {
|
|
1025
1949
|
const decision = this.session.permissions.evaluate(tool, input);
|
|
1026
1950
|
if (decision.behavior === "allow")
|
|
@@ -1031,6 +1955,7 @@ export class Agent {
|
|
|
1031
1955
|
toolCallId: call.id,
|
|
1032
1956
|
allowed: false,
|
|
1033
1957
|
reason: decision.message,
|
|
1958
|
+
...(decision.rule === "plan" ? { plan: true } : {}),
|
|
1034
1959
|
};
|
|
1035
1960
|
return false;
|
|
1036
1961
|
}
|
|
@@ -1040,7 +1965,7 @@ export class Agent {
|
|
|
1040
1965
|
title,
|
|
1041
1966
|
reason: decision.rule ? `matched rule ${decision.rule}` : undefined,
|
|
1042
1967
|
...(decision.rule ? { rule: decision.rule } : {}),
|
|
1043
|
-
|
|
1968
|
+
...this.interactionPlace(),
|
|
1044
1969
|
toolCallId: call.id,
|
|
1045
1970
|
};
|
|
1046
1971
|
yield { type: "permission_request", request };
|
|
@@ -1142,4 +2067,36 @@ function touchesSkills(toolName, input) {
|
|
|
1142
2067
|
// what is advertised, so re-scanning for those would be noise.
|
|
1143
2068
|
return target.includes("skills") && target.endsWith("SKILL.md");
|
|
1144
2069
|
}
|
|
2070
|
+
/**
|
|
2071
|
+
* A queue a callback pushes into and a generator drains, for turning an
|
|
2072
|
+
* external agent's callbacks into a stream of events as they happen.
|
|
2073
|
+
*/
|
|
2074
|
+
function channel() {
|
|
2075
|
+
const items = [];
|
|
2076
|
+
let wake;
|
|
2077
|
+
let closed = false;
|
|
2078
|
+
const poke = () => {
|
|
2079
|
+
wake?.();
|
|
2080
|
+
wake = undefined;
|
|
2081
|
+
};
|
|
2082
|
+
return {
|
|
2083
|
+
push(item) {
|
|
2084
|
+
items.push(item);
|
|
2085
|
+
poke();
|
|
2086
|
+
},
|
|
2087
|
+
close() {
|
|
2088
|
+
closed = true;
|
|
2089
|
+
poke();
|
|
2090
|
+
},
|
|
2091
|
+
async *drain() {
|
|
2092
|
+
for (;;) {
|
|
2093
|
+
while (items.length)
|
|
2094
|
+
yield items.shift();
|
|
2095
|
+
if (closed)
|
|
2096
|
+
return;
|
|
2097
|
+
await new Promise((resolve) => (wake = resolve));
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
1145
2102
|
//# sourceMappingURL=agent.js.map
|