jeopi-cli 16.2.23 → 16.2.25
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/CHANGELOG.md +33 -0
- package/dist/cli.js +3011 -2970
- package/dist/types/advisor/runtime.d.ts +9 -1
- package/dist/types/cli/update-cli.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/extensibility/plugins/loader.d.ts +4 -4
- package/dist/types/extensibility/plugins/trust.d.ts +30 -0
- package/dist/types/goals/completion-evidence.d.ts +21 -0
- package/dist/types/goals/runtime.d.ts +1 -1
- package/dist/types/goals/state.d.ts +3 -0
- package/dist/types/goals/tools/goal-tool.d.ts +2 -0
- package/dist/types/main.d.ts +20 -0
- package/dist/types/mnemopi/state.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/task/critic-gate.d.ts +34 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +6 -1
- package/dist/types/tools/subagent.d.ts +70 -0
- package/dist/types/tools/subagent.test.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +173 -0
- package/src/advisor/runtime.ts +66 -2
- package/src/cli/update-cli.ts +144 -0
- package/src/config/settings-schema.ts +14 -2
- package/src/extensibility/plugins/loader.ts +35 -9
- package/src/extensibility/plugins/trust.ts +133 -0
- package/src/goals/completion-evidence.ts +73 -0
- package/src/goals/runtime.ts +6 -1
- package/src/goals/state.ts +3 -0
- package/src/goals/tools/goal-tool.ts +16 -1
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/main.ts +56 -10
- package/src/memories/index.ts +21 -1
- package/src/mnemopi/state.ts +10 -2
- package/src/modes/interactive-mode.ts +21 -0
- package/src/prompts/memories/read-path.md +3 -1
- package/src/prompts/system/safety-kernel.md +17 -0
- package/src/prompts/system/tool-call-cycle-redirect.md +5 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/prompts/tools/subagent.md +29 -0
- package/src/session/agent-session.ts +68 -19
- package/src/session/session-context.ts +8 -2
- package/src/slash-commands/builtin-registry.ts +28 -0
- package/src/slash-commands/types.ts +2 -0
- package/src/system-prompt.ts +14 -2
- package/src/task/critic-gate.ts +46 -0
- package/src/task/index.ts +63 -2
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/index.ts +9 -1
- package/src/tools/subagent.test.ts +305 -0
- package/src/tools/subagent.ts +591 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent tool — unified control surface for `task`-spawned background
|
|
3
|
+
* subagents: list/inspect/await/cancel their AsyncJobManager job record, and
|
|
4
|
+
* steer/pause/resume the live agent session behind them.
|
|
5
|
+
*
|
|
6
|
+
* This composes existing primitives instead of owning new state:
|
|
7
|
+
* - {@link AsyncJobManager} (`type: "task"` jobs) is the source of truth for
|
|
8
|
+
* a spawn's *original* run outcome — completed/failed/cancelled/queued.
|
|
9
|
+
* - {@link AgentRegistry} is the source of truth for whether the underlying
|
|
10
|
+
* AgentSession is currently working right now (`running`) and whether it
|
|
11
|
+
* is still resident and addressable for a follow-up (`idle`/`parked`).
|
|
12
|
+
* This matters beyond the first run: a `resume`d subagent processes its
|
|
13
|
+
* follow-up as a real turn on the *same* session without registering a
|
|
14
|
+
* new async job, so the registry — not the original job — is the only
|
|
15
|
+
* live signal once a subagent has been resumed at least once.
|
|
16
|
+
* - {@link IrcBus.send} is the single delivery path for steer/resume/pause:
|
|
17
|
+
* it already injects into a running agent as steering, wakes an idle one
|
|
18
|
+
* with a real turn, and revives a parked one (see `irc/bus.ts`).
|
|
19
|
+
*
|
|
20
|
+
* jeopi has no literal "frozen mid-run" agent state — a `task` job resolves
|
|
21
|
+
* the moment the spawned agent's run stops (yields or runs out of work), and
|
|
22
|
+
* its session then simply sits `idle`/`parked`, addressable for a follow-up.
|
|
23
|
+
* So `pause` does not freeze anything: it delivers a steering message asking
|
|
24
|
+
* the agent to wrap up at its next safe boundary, which makes that run
|
|
25
|
+
* finish sooner than it otherwise would while leaving the session resident.
|
|
26
|
+
* `#pausedIds` (session-local, per tool instance) tracks "a pause was
|
|
27
|
+
* requested and nothing has re-engaged it yet" so `list`/`inspect` can report
|
|
28
|
+
* a genuine `paused` status instead of overloading `completed`. It clears the
|
|
29
|
+
* moment a `resume`/`steer` call successfully hands the subagent new work.
|
|
30
|
+
*
|
|
31
|
+
* Every operation is scoped to the calling agent's own spawns (`ownerId`),
|
|
32
|
+
* matching the `job` and `irc` tools — cross-agent inspection is impossible
|
|
33
|
+
* by design.
|
|
34
|
+
*/
|
|
35
|
+
import { type } from "arktype";
|
|
36
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "jeopi-agent-core";
|
|
37
|
+
import { formatDuration, prompt } from "jeopi-utils";
|
|
38
|
+
import type { AsyncJob, AsyncJobManager } from "../async";
|
|
39
|
+
import { IrcBus, type IrcDeliveryReceipt } from "../irc/bus";
|
|
40
|
+
import subagentDescription from "../prompts/tools/subagent.md" with { type: "text" };
|
|
41
|
+
import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
|
|
42
|
+
import type { ToolSession } from "./index";
|
|
43
|
+
import { isIrcEnabled } from "./irc";
|
|
44
|
+
import { ToolError } from "./tool-errors";
|
|
45
|
+
|
|
46
|
+
/** Same audience as `irc`: a subagent always has peers; a top-level session has peers only if it can still spawn via `task`. */
|
|
47
|
+
export const isSubagentToolEnabled = isIrcEnabled;
|
|
48
|
+
|
|
49
|
+
const subagentSchema = type({
|
|
50
|
+
action: type("'list' | 'inspect' | 'await' | 'cancel' | 'pause' | 'resume' | 'steer'").describe(
|
|
51
|
+
"subagent control action",
|
|
52
|
+
),
|
|
53
|
+
"id?": type("string").describe("single target subagent id (preferred for steer/resume/pause)"),
|
|
54
|
+
"ids?": type("string[]").describe(
|
|
55
|
+
"target subagent ids (list/inspect/await/cancel); omit to target every running spawn of this agent",
|
|
56
|
+
),
|
|
57
|
+
"limit?": type("number").describe("list/inspect: maximum subagents to return (default 10, max 50)"),
|
|
58
|
+
"verbosity?": type("'receipt' | 'preview' | 'full'").describe(
|
|
59
|
+
'output verbosity: receipt (default, <=280-char preview), preview (<=2000 chars), or full (<=12000 chars; requires explicit ids, not valid with action="list")',
|
|
60
|
+
),
|
|
61
|
+
"message?": type("string").describe("message to deliver (steer/resume)"),
|
|
62
|
+
"pause?": type("boolean").describe("steer: also request a pause after delivering the message"),
|
|
63
|
+
"timeout_ms?": type("number").describe("await timeout in milliseconds (0 waits indefinitely)"),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
type SubagentParams = typeof subagentSchema.infer;
|
|
67
|
+
type Verbosity = "receipt" | "preview" | "full";
|
|
68
|
+
|
|
69
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 30_000;
|
|
70
|
+
const MAX_AWAIT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
71
|
+
const DEFAULT_LIST_LIMIT = 10;
|
|
72
|
+
const MAX_LIST_LIMIT = 50;
|
|
73
|
+
const DEFAULT_VERBOSITY: Verbosity = "receipt";
|
|
74
|
+
const RECEIPT_PREVIEW_CHARS = 280;
|
|
75
|
+
const PREVIEW_CHARS = 2_000;
|
|
76
|
+
const FULL_PREVIEW_CHARS = 12_000;
|
|
77
|
+
const DEFAULT_PAUSE_DIRECTIVE =
|
|
78
|
+
"Stop at the next safe boundary and yield. You can be resumed later with more instructions.";
|
|
79
|
+
const DEFAULT_RESUME_MESSAGE = "Continue from where you left off.";
|
|
80
|
+
|
|
81
|
+
export type SubagentStatus = "queued" | "running" | "paused" | "completed" | "failed" | "cancelled" | "not_found";
|
|
82
|
+
|
|
83
|
+
export interface SubagentSnapshot {
|
|
84
|
+
id: string;
|
|
85
|
+
status: SubagentStatus;
|
|
86
|
+
/** True when the underlying agent session is still resident (idle/parked) and addressable via a follow-up `resume`/`steer`. */
|
|
87
|
+
resumable: boolean;
|
|
88
|
+
label: string;
|
|
89
|
+
durationMs: number;
|
|
90
|
+
displayName?: string;
|
|
91
|
+
activity?: string;
|
|
92
|
+
resultText?: string;
|
|
93
|
+
errorText?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SubagentToolDetails {
|
|
97
|
+
action: SubagentParams["action"];
|
|
98
|
+
subagents: SubagentSnapshot[];
|
|
99
|
+
/** Present when `limit` truncated the result set. */
|
|
100
|
+
truncated?: number;
|
|
101
|
+
cancelled?: { id: string; status: "cancelled" | "not_found" | "already_completed" }[];
|
|
102
|
+
receipt?: { to: string; outcome: "injected" | "woken" | "revived" | "failed"; error?: string };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isResumable(ref: AgentRef | undefined): boolean {
|
|
106
|
+
return !!ref && (ref.status === "idle" || ref.status === "parked");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function errorResult(text: string, action: SubagentParams["action"]): AgentToolResult<SubagentToolDetails> {
|
|
110
|
+
return { content: [{ type: "text", text }], details: { action, subagents: [] }, isError: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentToolDetails> {
|
|
114
|
+
readonly name = "subagent";
|
|
115
|
+
readonly approval = "read" as const;
|
|
116
|
+
readonly label = "Subagent";
|
|
117
|
+
readonly summary = "List, inspect, await, cancel, pause, resume, and steer background task subagents";
|
|
118
|
+
readonly description: string;
|
|
119
|
+
readonly parameters = subagentSchema;
|
|
120
|
+
readonly strict = true;
|
|
121
|
+
readonly interruptible = true;
|
|
122
|
+
readonly loadMode = "discoverable";
|
|
123
|
+
|
|
124
|
+
/** Ids with an outstanding `pause` request that no `resume`/`steer`/live-run has cleared yet. Session-local. */
|
|
125
|
+
readonly #pausedIds = new Set<string>();
|
|
126
|
+
|
|
127
|
+
constructor(private readonly session: ToolSession) {
|
|
128
|
+
this.description = prompt.render(subagentDescription);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static createIf(session: ToolSession): SubagentTool | null {
|
|
132
|
+
if (!isSubagentToolEnabled(session.settings, session.taskDepth ?? 0)) return null;
|
|
133
|
+
if (!session.agentRegistry || !session.getAgentId) return null;
|
|
134
|
+
return new SubagentTool(session);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async execute(
|
|
138
|
+
_toolCallId: string,
|
|
139
|
+
params: SubagentParams,
|
|
140
|
+
signal?: AbortSignal,
|
|
141
|
+
onUpdate?: AgentToolUpdateCallback<SubagentToolDetails>,
|
|
142
|
+
_context?: AgentToolContext,
|
|
143
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
144
|
+
const registry = this.session.agentRegistry;
|
|
145
|
+
const senderId = this.session.getAgentId?.() ?? null;
|
|
146
|
+
if (!registry || !senderId) {
|
|
147
|
+
return errorResult("Subagent control is unavailable in this session.", params.action);
|
|
148
|
+
}
|
|
149
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
150
|
+
if (verbosity === "full" && (params.action === "list" || !(params.ids?.length || params.id))) {
|
|
151
|
+
throw new ToolError(
|
|
152
|
+
'`verbosity="full"` cannot be used with `list` and requires explicit `id`/`ids` so broad inspection cannot inline retained subagent output.',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const manager = this.session.asyncJobManager;
|
|
156
|
+
const ownerId = senderId;
|
|
157
|
+
|
|
158
|
+
switch (params.action) {
|
|
159
|
+
case "list":
|
|
160
|
+
return this.#list(manager, registry, ownerId, params);
|
|
161
|
+
case "inspect":
|
|
162
|
+
return this.#inspect(manager, registry, ownerId, params);
|
|
163
|
+
case "await":
|
|
164
|
+
return this.#await(manager, registry, ownerId, params, signal, onUpdate);
|
|
165
|
+
case "cancel":
|
|
166
|
+
return this.#cancel(manager, registry, ownerId, params);
|
|
167
|
+
case "steer":
|
|
168
|
+
return this.#steer(registry, senderId, params);
|
|
169
|
+
case "resume":
|
|
170
|
+
return this.#resume(manager, registry, senderId, params);
|
|
171
|
+
case "pause":
|
|
172
|
+
return this.#pause(manager, registry, senderId, params);
|
|
173
|
+
default:
|
|
174
|
+
return errorResult("Unknown subagent action.", params.action);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#taskJobs(manager: AsyncJobManager | undefined, ownerId: string): AsyncJob[] {
|
|
179
|
+
if (!manager) return [];
|
|
180
|
+
return manager.getAllJobs({ ownerId }).filter(job => job.type === "task");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#targetIds(params: SubagentParams): string[] | undefined {
|
|
184
|
+
if (params.ids?.length) return params.ids;
|
|
185
|
+
if (params.id) return [params.id];
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#visibleJob(manager: AsyncJobManager | undefined, ownerId: string, id: string): AsyncJob | undefined {
|
|
190
|
+
const job = manager?.getJob(id);
|
|
191
|
+
if (job?.type !== "task" || job.ownerId !== ownerId) return undefined;
|
|
192
|
+
return job;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Live status combines the registry (authoritative for "is it working
|
|
197
|
+
* right now", including turns started by a `resume` that never touch
|
|
198
|
+
* AsyncJobManager) with the original job record (authoritative for the
|
|
199
|
+
* first run's terminal outcome) and the local pause bookkeeping.
|
|
200
|
+
*/
|
|
201
|
+
#status(job: AsyncJob | undefined, ref: AgentRef | undefined, id: string): SubagentStatus {
|
|
202
|
+
if (!job && !ref) return "not_found";
|
|
203
|
+
if (job?.queued) return "queued";
|
|
204
|
+
// `pause` is advisory and does not itself flip the registry — the ref
|
|
205
|
+
// only reaches `running` again once a later `resume`/`steer` actually
|
|
206
|
+
// engages the subagent, and those already clear `#pausedIds` on success.
|
|
207
|
+
// So a still-`running` ref never means "the pause was a no-op"; it just
|
|
208
|
+
// means the pause hasn't taken effect yet.
|
|
209
|
+
if (ref?.status === "running") return "running";
|
|
210
|
+
if (this.#pausedIds.has(id)) return "paused";
|
|
211
|
+
if (job) return job.status;
|
|
212
|
+
return ref?.status === "aborted" ? "cancelled" : "completed";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#snapshot(job: AsyncJob | undefined, ref: AgentRef | undefined, id: string, verbosity: Verbosity): SubagentSnapshot {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const width =
|
|
218
|
+
verbosity === "full" ? FULL_PREVIEW_CHARS : verbosity === "preview" ? PREVIEW_CHARS : RECEIPT_PREVIEW_CHARS;
|
|
219
|
+
return {
|
|
220
|
+
id,
|
|
221
|
+
status: this.#status(job, ref, id),
|
|
222
|
+
resumable: isResumable(ref),
|
|
223
|
+
label: job?.label ?? ref?.displayName ?? id,
|
|
224
|
+
durationMs: job ? Math.max(0, now - job.startTime) : 0,
|
|
225
|
+
...(ref?.displayName ? { displayName: ref.displayName } : {}),
|
|
226
|
+
...(ref?.activity ? { activity: ref.activity } : {}),
|
|
227
|
+
...(job?.resultText ? { resultText: job.resultText.slice(0, width) } : {}),
|
|
228
|
+
...(job?.errorText ? { errorText: job.errorText.slice(0, width) } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#snapshotLine(s: SubagentSnapshot, verbosity: Verbosity): string {
|
|
233
|
+
if (verbosity === "receipt") return `- ${s.id} [${s.status}]`;
|
|
234
|
+
const extras = [s.resumable ? "resumable" : undefined, formatDuration(s.durationMs)].filter(Boolean);
|
|
235
|
+
return `- ${s.id} [${s.status}] — ${extras.join(", ")}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Applies a resolved (already clamped) `limit` (most recently started first) and reports how many were hidden. */
|
|
239
|
+
#applyLimit<T extends SubagentSnapshot>(subagents: T[], limit: number): { kept: T[]; truncated?: number } {
|
|
240
|
+
if (subagents.length <= limit) return { kept: subagents };
|
|
241
|
+
const sorted = [...subagents].sort((a, b) => b.durationMs - a.durationMs);
|
|
242
|
+
return { kept: sorted.slice(0, limit), truncated: subagents.length - limit };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#clampLimit(value: number | undefined, fallback: number): number {
|
|
246
|
+
return Math.min(MAX_LIST_LIMIT, Math.max(1, Math.floor(value ?? fallback)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#list(
|
|
250
|
+
manager: AsyncJobManager | undefined,
|
|
251
|
+
registry: AgentRegistry,
|
|
252
|
+
ownerId: string,
|
|
253
|
+
params: SubagentParams,
|
|
254
|
+
): AgentToolResult<SubagentToolDetails> {
|
|
255
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
256
|
+
const limit = this.#clampLimit(params.limit, DEFAULT_LIST_LIMIT);
|
|
257
|
+
const jobs = this.#taskJobs(manager, ownerId);
|
|
258
|
+
const all = jobs.map(job => this.#snapshot(job, registry.get(job.id), job.id, verbosity));
|
|
259
|
+
const { kept: subagents, truncated } = this.#applyLimit(all, limit);
|
|
260
|
+
const lines =
|
|
261
|
+
subagents.length === 0
|
|
262
|
+
? ["No subagents spawned yet."]
|
|
263
|
+
: [`${all.length} subagent(s):`, ...subagents.map(s => this.#snapshotLine(s, verbosity))];
|
|
264
|
+
if (truncated) lines.push(`… ${truncated} more (raise \`limit\`, max ${MAX_LIST_LIMIT}, to see them)`);
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
267
|
+
details: { action: "list", subagents, ...(truncated ? { truncated } : {}) },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#inspect(
|
|
272
|
+
manager: AsyncJobManager | undefined,
|
|
273
|
+
registry: AgentRegistry,
|
|
274
|
+
ownerId: string,
|
|
275
|
+
params: SubagentParams,
|
|
276
|
+
): AgentToolResult<SubagentToolDetails> {
|
|
277
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
278
|
+
const ids = this.#targetIds(params);
|
|
279
|
+
const jobs = ids
|
|
280
|
+
? ids.map(id => this.#visibleJob(manager, ownerId, id)).filter((j): j is AsyncJob => j != null)
|
|
281
|
+
: this.#taskJobs(manager, ownerId).filter(job => job.status === "running");
|
|
282
|
+
if (ids && jobs.length === 0) {
|
|
283
|
+
return errorResult(`No matching subagents found for: ${ids.join(", ")}`, "inspect");
|
|
284
|
+
}
|
|
285
|
+
const all = jobs.map(job => this.#snapshot(job, registry.get(job.id), job.id, verbosity));
|
|
286
|
+
// Explicit ids are never truncated — the caller already bounded the set.
|
|
287
|
+
// Omitting ids falls back to every running spawn, capped at MAX_LIST_LIMIT.
|
|
288
|
+
const { kept: subagents, truncated } = ids
|
|
289
|
+
? { kept: all, truncated: undefined }
|
|
290
|
+
: this.#applyLimit(all, MAX_LIST_LIMIT);
|
|
291
|
+
const lines = subagents.map(s => {
|
|
292
|
+
const parts = [this.#snapshotLine(s, verbosity)];
|
|
293
|
+
if (s.resultText) parts.push(` ${s.resultText}`);
|
|
294
|
+
if (s.errorText) parts.push(` error: ${s.errorText}`);
|
|
295
|
+
return parts.join("\n");
|
|
296
|
+
});
|
|
297
|
+
if (truncated) lines.push(`… ${truncated} more running (max ${MAX_LIST_LIMIT} shown)`);
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: lines.length ? lines.join("\n") : "No running subagents." }],
|
|
300
|
+
details: { action: "inspect", subagents, ...(truncated ? { truncated } : {}) },
|
|
301
|
+
useless: subagents.length === 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async #await(
|
|
306
|
+
manager: AsyncJobManager | undefined,
|
|
307
|
+
registry: AgentRegistry,
|
|
308
|
+
ownerId: string,
|
|
309
|
+
params: SubagentParams,
|
|
310
|
+
signal: AbortSignal | undefined,
|
|
311
|
+
onUpdate: AgentToolUpdateCallback<SubagentToolDetails> | undefined,
|
|
312
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
313
|
+
if (!manager) return errorResult("Async execution is disabled; no background subagents to await.", "await");
|
|
314
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
315
|
+
const ids = this.#targetIds(params);
|
|
316
|
+
const watched = ids
|
|
317
|
+
? ids.map(id => this.#visibleJob(manager, ownerId, id)).filter((j): j is AsyncJob => j != null)
|
|
318
|
+
: manager
|
|
319
|
+
.getRunningJobs({ ownerId })
|
|
320
|
+
.filter(job => job.type === "task")
|
|
321
|
+
.slice(0, MAX_LIST_LIMIT);
|
|
322
|
+
if (watched.length === 0) {
|
|
323
|
+
const text = ids?.length
|
|
324
|
+
? `No matching subagents found for: ${ids.join(", ")}`
|
|
325
|
+
: "No running subagents to await.";
|
|
326
|
+
return { content: [{ type: "text", text }], details: { action: "await", subagents: [] }, useless: true };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const snapshotAll = () =>
|
|
330
|
+
watched.map(job => this.#snapshot(manager.getJob(job.id) ?? job, registry.get(job.id), job.id, verbosity));
|
|
331
|
+
|
|
332
|
+
const running = watched.filter(job => job.status === "running");
|
|
333
|
+
if (running.length === 0) {
|
|
334
|
+
const subagents = snapshotAll();
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: subagents.map(s => this.#snapshotLine(s, verbosity)).join("\n") }],
|
|
337
|
+
details: { action: "await", subagents },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const timeoutMs = Math.min(
|
|
342
|
+
MAX_AWAIT_TIMEOUT_MS,
|
|
343
|
+
Math.max(0, Math.floor(params.timeout_ms ?? DEFAULT_AWAIT_TIMEOUT_MS)),
|
|
344
|
+
);
|
|
345
|
+
const racePromises: Promise<unknown>[] = running.map(job => job.promise);
|
|
346
|
+
const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers<void>();
|
|
347
|
+
const timeoutHandle = timeoutMs > 0 ? setTimeout(() => timeoutResolve(), timeoutMs) : undefined;
|
|
348
|
+
if (timeoutMs > 0) racePromises.push(timeoutPromise);
|
|
349
|
+
|
|
350
|
+
const watchedIds = running.map(job => job.id);
|
|
351
|
+
manager.watchJobs(watchedIds);
|
|
352
|
+
|
|
353
|
+
const PROGRESS_INTERVAL_MS = 500;
|
|
354
|
+
const emitProgress = () => {
|
|
355
|
+
if (!onUpdate) return;
|
|
356
|
+
onUpdate({ content: [{ type: "text", text: "" }], details: { action: "await", subagents: snapshotAll() } });
|
|
357
|
+
};
|
|
358
|
+
const progressTimer = onUpdate ? setInterval(emitProgress, PROGRESS_INTERVAL_MS) : undefined;
|
|
359
|
+
emitProgress();
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
if (signal) {
|
|
363
|
+
const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers<void>();
|
|
364
|
+
const onAbort = () => abortResolve();
|
|
365
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
366
|
+
try {
|
|
367
|
+
await Promise.race([...racePromises, abortPromise]);
|
|
368
|
+
} finally {
|
|
369
|
+
signal.removeEventListener("abort", onAbort);
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
await Promise.race(racePromises);
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
manager.unwatchJobs(watchedIds);
|
|
376
|
+
clearTimeout(timeoutHandle);
|
|
377
|
+
if (progressTimer) clearInterval(progressTimer);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const subagents = snapshotAll();
|
|
381
|
+
return {
|
|
382
|
+
content: [{ type: "text", text: subagents.map(s => this.#snapshotLine(s, verbosity)).join("\n") }],
|
|
383
|
+
details: { action: "await", subagents },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#cancel(
|
|
388
|
+
manager: AsyncJobManager | undefined,
|
|
389
|
+
registry: AgentRegistry,
|
|
390
|
+
ownerId: string,
|
|
391
|
+
params: SubagentParams,
|
|
392
|
+
): AgentToolResult<SubagentToolDetails> {
|
|
393
|
+
const ids = this.#targetIds(params);
|
|
394
|
+
if (!ids?.length) {
|
|
395
|
+
throw new ToolError('`id` or `ids` is required for action="cancel".');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const cancelled: SubagentToolDetails["cancelled"] = [];
|
|
399
|
+
const resolved: { id: string; ref: AgentRef | undefined; job: AsyncJob | undefined }[] = [];
|
|
400
|
+
for (const id of ids) {
|
|
401
|
+
const ref = registry.get(id);
|
|
402
|
+
const job = this.#visibleJob(manager, ownerId, id);
|
|
403
|
+
resolved.push({ id, ref, job });
|
|
404
|
+
this.#pausedIds.delete(id);
|
|
405
|
+
if (!job && !ref) {
|
|
406
|
+
cancelled.push({ id, status: "not_found" });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// A job still tracked as running (first run in flight) cancels through
|
|
410
|
+
// AsyncJobManager. A live turn started by a later `resume`/`steer` has
|
|
411
|
+
// no job to cancel — abort the session directly instead. Either path
|
|
412
|
+
// mutates the same `job`/`ref` object in place, so the snapshot below
|
|
413
|
+
// reuses these references instead of re-querying the manager/registry.
|
|
414
|
+
if (job?.status === "running" && manager) {
|
|
415
|
+
cancelled.push({ id, status: manager.cancel(id, { ownerId }) ? "cancelled" : "already_completed" });
|
|
416
|
+
} else if (ref?.status === "running" && ref.session) {
|
|
417
|
+
ref.session.abort({ reason: "Cancelled via subagent tool" }).catch(() => {});
|
|
418
|
+
cancelled.push({ id, status: "cancelled" });
|
|
419
|
+
} else {
|
|
420
|
+
cancelled.push({ id, status: "already_completed" });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
424
|
+
const subagents = resolved.map(({ id, job, ref }) => this.#snapshot(job, ref, id, verbosity));
|
|
425
|
+
const lines = cancelled.map(c => `- ${c.id}: ${c.status}`);
|
|
426
|
+
return {
|
|
427
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
428
|
+
details: { action: "cancel", subagents, cancelled },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Shared `IrcBus.send` + snapshot + result-shaping tail for steer/resume/pause,
|
|
434
|
+
* which otherwise differ only in the delivered body, the success wording, and
|
|
435
|
+
* how `#pausedIds` reacts to a successful delivery.
|
|
436
|
+
*/
|
|
437
|
+
async #deliver(
|
|
438
|
+
manager: AsyncJobManager | undefined,
|
|
439
|
+
registry: AgentRegistry,
|
|
440
|
+
senderId: string,
|
|
441
|
+
id: string,
|
|
442
|
+
body: string,
|
|
443
|
+
action: SubagentParams["action"],
|
|
444
|
+
verbosity: Verbosity,
|
|
445
|
+
successText: (outcome: IrcDeliveryReceipt["outcome"]) => string,
|
|
446
|
+
onDelivered?: (outcome: IrcDeliveryReceipt["outcome"]) => void,
|
|
447
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
448
|
+
const receipt = await IrcBus.global().send({ from: senderId, to: id, body });
|
|
449
|
+
onDelivered?.(receipt.outcome);
|
|
450
|
+
const subagents = [this.#snapshot(manager?.getJob(id), registry.get(id), id, verbosity)];
|
|
451
|
+
const text =
|
|
452
|
+
receipt.outcome === "failed"
|
|
453
|
+
? `Failed to ${action} ${id}: ${receipt.error ?? "unknown error"}`
|
|
454
|
+
: successText(receipt.outcome);
|
|
455
|
+
return {
|
|
456
|
+
content: [{ type: "text", text }],
|
|
457
|
+
details: { action, subagents, receipt },
|
|
458
|
+
isError: receipt.outcome === "failed",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Appends the `[pause requested]` directive to an optional message body, used by both `steer ... pause: true` and `pause`. */
|
|
463
|
+
#withPauseDirective(message?: string): string {
|
|
464
|
+
return message
|
|
465
|
+
? `${message}\n\n[pause requested] ${DEFAULT_PAUSE_DIRECTIVE}`
|
|
466
|
+
: `[pause requested] ${DEFAULT_PAUSE_DIRECTIVE}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async #steer(
|
|
470
|
+
registry: AgentRegistry,
|
|
471
|
+
senderId: string,
|
|
472
|
+
params: SubagentParams,
|
|
473
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
474
|
+
const id = params.id;
|
|
475
|
+
const message = params.message?.trim();
|
|
476
|
+
if (!id) throw new ToolError('`id` is required for action="steer".');
|
|
477
|
+
if (!message) throw new ToolError('`message` is required for action="steer".');
|
|
478
|
+
if (id === senderId) throw new ToolError("Cannot steer yourself.");
|
|
479
|
+
|
|
480
|
+
const body = params.pause ? this.#withPauseDirective(message) : message;
|
|
481
|
+
return this.#deliver(
|
|
482
|
+
this.session.asyncJobManager,
|
|
483
|
+
registry,
|
|
484
|
+
senderId,
|
|
485
|
+
id,
|
|
486
|
+
body,
|
|
487
|
+
"steer",
|
|
488
|
+
params.verbosity ?? DEFAULT_VERBOSITY,
|
|
489
|
+
outcome => `Steered ${id} (${outcome}).`,
|
|
490
|
+
outcome => {
|
|
491
|
+
if (outcome === "failed") return;
|
|
492
|
+
if (params.pause) this.#pausedIds.add(id);
|
|
493
|
+
else this.#pausedIds.delete(id);
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async #resume(
|
|
499
|
+
manager: AsyncJobManager | undefined,
|
|
500
|
+
registry: AgentRegistry,
|
|
501
|
+
senderId: string,
|
|
502
|
+
params: SubagentParams,
|
|
503
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
504
|
+
const id = params.id;
|
|
505
|
+
if (!id) throw new ToolError('`id` is required for action="resume".');
|
|
506
|
+
if (id === senderId) throw new ToolError("Cannot resume yourself.");
|
|
507
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
508
|
+
|
|
509
|
+
const ref = registry.get(id);
|
|
510
|
+
const job = this.#visibleJob(manager, senderId, id);
|
|
511
|
+
if (!ref && !job) return errorResult(`Unknown subagent "${id}".`, "resume");
|
|
512
|
+
|
|
513
|
+
if (ref?.status === "running") {
|
|
514
|
+
return {
|
|
515
|
+
content: [{ type: "text", text: `${id} is already running; nothing to resume.` }],
|
|
516
|
+
details: { action: "resume", subagents: [this.#snapshot(job, ref, id, verbosity)] },
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (job?.queued) {
|
|
520
|
+
return {
|
|
521
|
+
content: [{ type: "text", text: `${id} is queued, already waiting for a spawn slot.` }],
|
|
522
|
+
details: { action: "resume", subagents: [this.#snapshot(job, ref, id, verbosity)] },
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// GJC parity: a `paused` subagent has saved context it stopped *because
|
|
527
|
+
// we asked it to* — resume can proceed with a default continuation body,
|
|
528
|
+
// the same way GJC's native `resumeSubagent` continues a non-terminal
|
|
529
|
+
// record without requiring a message. A subagent that simply finished on
|
|
530
|
+
// its own (completed/failed/cancelled) has no such known intent, so that
|
|
531
|
+
// case still requires an explicit `message`.
|
|
532
|
+
const isPaused = this.#pausedIds.has(id);
|
|
533
|
+
const message = params.message?.trim();
|
|
534
|
+
if (!message && !isPaused) {
|
|
535
|
+
throw new ToolError(
|
|
536
|
+
`\`message\` is required to resume "${id}" — it has no pending work to continue on its own.`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
const body = message || DEFAULT_RESUME_MESSAGE;
|
|
540
|
+
return this.#deliver(
|
|
541
|
+
manager,
|
|
542
|
+
registry,
|
|
543
|
+
senderId,
|
|
544
|
+
id,
|
|
545
|
+
body,
|
|
546
|
+
"resume",
|
|
547
|
+
verbosity,
|
|
548
|
+
outcome => `Resumed ${id} (${outcome}).`,
|
|
549
|
+
outcome => {
|
|
550
|
+
if (outcome !== "failed") this.#pausedIds.delete(id);
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async #pause(
|
|
556
|
+
manager: AsyncJobManager | undefined,
|
|
557
|
+
registry: AgentRegistry,
|
|
558
|
+
senderId: string,
|
|
559
|
+
params: SubagentParams,
|
|
560
|
+
): Promise<AgentToolResult<SubagentToolDetails>> {
|
|
561
|
+
const id = params.id;
|
|
562
|
+
if (!id) throw new ToolError('`id` is required for action="pause".');
|
|
563
|
+
if (id === senderId) throw new ToolError("Cannot pause yourself.");
|
|
564
|
+
const verbosity = params.verbosity ?? DEFAULT_VERBOSITY;
|
|
565
|
+
|
|
566
|
+
const ref = registry.get(id);
|
|
567
|
+
const job = this.#visibleJob(manager, senderId, id);
|
|
568
|
+
if (ref?.status !== "running") {
|
|
569
|
+
return {
|
|
570
|
+
content: [{ type: "text", text: `${id} is not running; nothing to pause.` }],
|
|
571
|
+
details: { action: "pause", subagents: [this.#snapshot(job, ref, id, verbosity)] },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const directive = this.#withPauseDirective(params.message?.trim());
|
|
576
|
+
return this.#deliver(
|
|
577
|
+
manager,
|
|
578
|
+
registry,
|
|
579
|
+
senderId,
|
|
580
|
+
id,
|
|
581
|
+
directive,
|
|
582
|
+
"pause",
|
|
583
|
+
verbosity,
|
|
584
|
+
outcome =>
|
|
585
|
+
`Requested a pause on ${id} (${outcome}). It will finish its current step and go idle — resume it later with a message.`,
|
|
586
|
+
outcome => {
|
|
587
|
+
if (outcome !== "failed") this.#pausedIds.add(id);
|
|
588
|
+
},
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|