pi-subagents 0.17.5 → 0.18.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/CHANGELOG.md +16 -0
- package/README.md +19 -19
- package/agents/oracle-executor.md +1 -1
- package/agents/scout.md +1 -1
- package/async-execution.ts +22 -2
- package/async-job-tracker.ts +70 -7
- package/async-status.ts +37 -15
- package/chain-execution.ts +29 -4
- package/execution.ts +18 -30
- package/index.ts +118 -131
- package/install.mjs +2 -3
- package/intercom-bridge.ts +9 -0
- package/notify.ts +25 -6
- package/package.json +3 -6
- package/pi-args.ts +4 -0
- package/render.ts +15 -22
- package/result-watcher.ts +3 -5
- package/run-status.ts +134 -0
- package/schemas.ts +16 -12
- package/skills/pi-subagents/SKILL.md +21 -21
- package/slash-live-state.ts +0 -4
- package/subagent-control.ts +84 -42
- package/subagent-executor.ts +122 -11
- package/subagent-prompt-runtime.ts +6 -0
- package/subagent-runner.ts +118 -10
- package/subagents-status.ts +5 -1
- package/types.ts +29 -9
package/result-watcher.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.js";
|
|
5
5
|
import { createFileCoalescer } from "./file-coalescer.js";
|
|
6
|
-
import type
|
|
6
|
+
import { SUBAGENT_ASYNC_COMPLETE_EVENT, type SubagentState } from "./types.js";
|
|
7
7
|
|
|
8
8
|
function isNotFoundError(error: unknown): boolean {
|
|
9
9
|
return typeof error === "object"
|
|
@@ -36,13 +36,11 @@ export function createResultWatcher(
|
|
|
36
36
|
const now = Date.now();
|
|
37
37
|
const completionKey = buildCompletionKey(data, `result:${file}`);
|
|
38
38
|
if (markSeenWithTtl(state.completionSeen, completionKey, now, completionTtlMs)) {
|
|
39
|
-
|
|
40
|
-
fs.unlinkSync(resultPath);
|
|
41
|
-
} catch {}
|
|
39
|
+
fs.unlinkSync(resultPath);
|
|
42
40
|
return;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
pi.events.emit(
|
|
43
|
+
pi.events.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, data);
|
|
46
44
|
fs.unlinkSync(resultPath);
|
|
47
45
|
} catch (error) {
|
|
48
46
|
if (isNotFoundError(error)) return;
|
package/run-status.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
4
|
+
import { formatAsyncRunList, listAsyncRuns } from "./async-status.ts";
|
|
5
|
+
import { ASYNC_DIR, RESULTS_DIR, type Details } from "./types.ts";
|
|
6
|
+
import { findByPrefix, readStatus } from "./utils.ts";
|
|
7
|
+
|
|
8
|
+
export interface RunStatusParams {
|
|
9
|
+
action?: "status";
|
|
10
|
+
id?: string;
|
|
11
|
+
runId?: string;
|
|
12
|
+
dir?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function activityText(activityState: unknown, lastActivityAt: unknown): string | undefined {
|
|
16
|
+
if (typeof lastActivityAt !== "number") return undefined;
|
|
17
|
+
const seconds = Math.floor(Math.max(0, Date.now() - lastActivityAt) / 1000);
|
|
18
|
+
return activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function inspectSubagentStatus(params: RunStatusParams): AgentToolResult<Details> {
|
|
22
|
+
if (!params.id && !params.runId && !params.dir) {
|
|
23
|
+
try {
|
|
24
|
+
const runs = listAsyncRuns(ASYNC_DIR, { states: ["queued", "running"] });
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: "text", text: formatAsyncRunList(runs) }],
|
|
27
|
+
details: { mode: "single", results: [] },
|
|
28
|
+
};
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: "text", text: message }],
|
|
33
|
+
isError: true,
|
|
34
|
+
details: { mode: "single", results: [] },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let asyncDir: string | null = null;
|
|
40
|
+
let resolvedId = params.id ?? params.runId;
|
|
41
|
+
|
|
42
|
+
if (params.dir) {
|
|
43
|
+
asyncDir = path.resolve(params.dir);
|
|
44
|
+
} else if (resolvedId) {
|
|
45
|
+
const direct = path.join(ASYNC_DIR, resolvedId);
|
|
46
|
+
if (fs.existsSync(direct)) {
|
|
47
|
+
asyncDir = direct;
|
|
48
|
+
} else {
|
|
49
|
+
const match = findByPrefix(ASYNC_DIR, resolvedId);
|
|
50
|
+
if (match) {
|
|
51
|
+
asyncDir = match;
|
|
52
|
+
resolvedId = path.basename(match);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resultPath = resolvedId && !asyncDir ? findByPrefix(RESULTS_DIR, resolvedId, ".json") : null;
|
|
58
|
+
|
|
59
|
+
if (!asyncDir && !resultPath) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{ type: "text", text: "Async run not found. Provide id or dir." }],
|
|
62
|
+
isError: true,
|
|
63
|
+
details: { mode: "single", results: [] },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (asyncDir) {
|
|
68
|
+
let status;
|
|
69
|
+
try {
|
|
70
|
+
status = readStatus(asyncDir);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return {
|
|
74
|
+
content: [{ type: "text", text: message }],
|
|
75
|
+
isError: true,
|
|
76
|
+
details: { mode: "single", results: [] },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const logPath = path.join(asyncDir, `subagent-log-${resolvedId ?? "unknown"}.md`);
|
|
80
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
81
|
+
if (status) {
|
|
82
|
+
const stepsTotal = status.steps?.length ?? 1;
|
|
83
|
+
const current = status.currentStep !== undefined ? status.currentStep + 1 : undefined;
|
|
84
|
+
const stepLine = current !== undefined ? `Step: ${current}/${stepsTotal}` : `Steps: ${stepsTotal}`;
|
|
85
|
+
const started = new Date(status.startedAt).toISOString();
|
|
86
|
+
const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
|
|
87
|
+
const statusActivityText = status.state === "running" ? activityText(status.activityState, status.lastActivityAt) : undefined;
|
|
88
|
+
|
|
89
|
+
const lines = [
|
|
90
|
+
`Run: ${status.runId}`,
|
|
91
|
+
`State: ${status.state}`,
|
|
92
|
+
statusActivityText ? `Activity: ${statusActivityText}` : undefined,
|
|
93
|
+
`Mode: ${status.mode}`,
|
|
94
|
+
stepLine,
|
|
95
|
+
`Started: ${started}`,
|
|
96
|
+
`Updated: ${updated}`,
|
|
97
|
+
`Dir: ${asyncDir}`,
|
|
98
|
+
].filter((line): line is string => Boolean(line));
|
|
99
|
+
for (const [index, step] of (status.steps ?? []).entries()) {
|
|
100
|
+
const stepActivityText = step.status === "running" ? activityText(step.activityState, step.lastActivityAt) : undefined;
|
|
101
|
+
lines.push(`Step ${index + 1}: ${step.agent} ${step.status}${stepActivityText ? `, ${stepActivityText}` : ""}`);
|
|
102
|
+
}
|
|
103
|
+
if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
|
|
104
|
+
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
105
|
+
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
106
|
+
|
|
107
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (resultPath) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = fs.readFileSync(resultPath, "utf-8");
|
|
114
|
+
const data = JSON.parse(raw) as { id?: string; success?: boolean; summary?: string; exitCode?: number; state?: string };
|
|
115
|
+
const status = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
|
|
116
|
+
const lines = [`Run: ${data.id ?? resolvedId}`, `State: ${status}`, `Result: ${resultPath}`];
|
|
117
|
+
if (data.summary) lines.push("", data.summary);
|
|
118
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: `Failed to read async result file: ${message}` }],
|
|
123
|
+
isError: true,
|
|
124
|
+
details: { mode: "single", results: [] },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: "Status file not found." }],
|
|
131
|
+
isError: true,
|
|
132
|
+
details: { mode: "single", results: [] },
|
|
133
|
+
};
|
|
134
|
+
}
|
package/schemas.ts
CHANGED
|
@@ -58,10 +58,14 @@ export const ParallelStepSchema = Type.Object({
|
|
|
58
58
|
export const ChainItem = Type.Any({ description: "Chain step: either {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
|
|
59
59
|
|
|
60
60
|
export const ControlOverrides = Type.Object({
|
|
61
|
-
enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
|
|
62
|
+
needsAttentionAfterMs: Type.Optional(Type.Integer({ minimum: 1, description: "No-observed-activity window before a run needs attention" })),
|
|
63
|
+
notifyOn: Type.Optional(Type.Array(Type.String({ enum: ["needs_attention"] }), {
|
|
64
|
+
description: "Control event types that should notify the parent/orchestrator. Defaults to needs_attention.",
|
|
65
|
+
})),
|
|
66
|
+
notifyChannels: Type.Optional(Type.Array(Type.String({ enum: ["event", "async", "intercom"] }), {
|
|
67
|
+
description: "Notification channels to use when available. Defaults to event, async, and intercom.",
|
|
68
|
+
})),
|
|
65
69
|
});
|
|
66
70
|
|
|
67
71
|
export const SubagentParams = Type.Object({
|
|
@@ -69,10 +73,16 @@ export const SubagentParams = Type.Object({
|
|
|
69
73
|
task: Type.Optional(Type.String({ description: "Task (SINGLE mode)" })),
|
|
70
74
|
// Management action (when present, tool operates in management mode)
|
|
71
75
|
action: Type.Optional(Type.String({
|
|
72
|
-
description: "Action: management ('list','get','create','update','delete') or control ('interrupt'). Omit for execution mode."
|
|
76
|
+
description: "Action: management ('list','get','create','update','delete') or control ('status','interrupt'). Omit for execution mode."
|
|
77
|
+
})),
|
|
78
|
+
id: Type.Optional(Type.String({
|
|
79
|
+
description: "Run id or prefix for action='status' or action='interrupt'."
|
|
73
80
|
})),
|
|
74
81
|
runId: Type.Optional(Type.String({
|
|
75
|
-
description: "Target run ID for action='interrupt'. Defaults to the most recently active controllable run in this session."
|
|
82
|
+
description: "Target run ID for action='interrupt'. Defaults to the most recently active controllable run in this session. Prefer id for new calls."
|
|
83
|
+
})),
|
|
84
|
+
dir: Type.Optional(Type.String({
|
|
85
|
+
description: "Async run directory for action='status'."
|
|
76
86
|
})),
|
|
77
87
|
// Chain identifier for management (can't reuse 'chain' — that's the execution array)
|
|
78
88
|
chainName: Type.Optional(Type.String({
|
|
@@ -112,9 +122,3 @@ export const SubagentParams = Type.Object({
|
|
|
112
122
|
skill: Type.Optional(SkillOverride),
|
|
113
123
|
model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
|
|
114
124
|
});
|
|
115
|
-
|
|
116
|
-
export const StatusParams = Type.Object({
|
|
117
|
-
action: Type.Optional(Type.String({ description: "Action: 'list' to show active async runs, or omit to inspect one run by id/dir" })),
|
|
118
|
-
id: Type.Optional(Type.String({ description: "Async run id or prefix" })),
|
|
119
|
-
dir: Type.Optional(Type.String({ description: "Async run directory (overrides id search)" })),
|
|
120
|
-
});
|
|
@@ -20,12 +20,12 @@ agents into a workflow, or create/edit agents and chains on demand.
|
|
|
20
20
|
- **Recon and planning**: use `scout` or `context-builder`, then `planner`
|
|
21
21
|
- **Parallel exploration**: run multiple non-conflicting tasks concurrently
|
|
22
22
|
- **Long-running work**: launch async/background runs and inspect them later
|
|
23
|
-
- **Subagent control**: watch
|
|
23
|
+
- **Subagent control**: watch needs-attention signals and soft-interrupt only when a delegated run is genuinely blocked
|
|
24
24
|
- **Agent authoring**: create, update, or override agents and chains for a project
|
|
25
25
|
|
|
26
26
|
## Tool vs Slash Commands
|
|
27
27
|
|
|
28
|
-
Agents can use the `subagent(...)`
|
|
28
|
+
Agents can use the `subagent(...)` tool directly for execution, management, status, and control.
|
|
29
29
|
Humans often use the slash-command layer instead:
|
|
30
30
|
|
|
31
31
|
- `/run` — launch a single agent
|
|
@@ -45,14 +45,14 @@ and user/project agents override builtins with the same name.
|
|
|
45
45
|
| Agent | Purpose | Model | Typical output / role |
|
|
46
46
|
|-------|---------|-------|------------------------|
|
|
47
47
|
| `scout` | Fast codebase recon | `openai-codex/gpt-5.4-mini` | Writes `context.md` handoff material |
|
|
48
|
-
| `planner` | Creates implementation plans | `openai-codex/gpt-5.
|
|
49
|
-
| `worker` | General implementation | `openai-codex/gpt-5.
|
|
50
|
-
| `reviewer` | Review-and-fix specialist | `openai-codex/gpt-5.
|
|
51
|
-
| `context-builder` | Requirements/codebase handoff builder | `openai-codex/gpt-5.
|
|
52
|
-
| `researcher` | Web research brief generator | `openai-codex/gpt-5.
|
|
48
|
+
| `planner` | Creates implementation plans | `openai-codex/gpt-5.5` | Writes `plan.md` |
|
|
49
|
+
| `worker` | General implementation | `openai-codex/gpt-5.5` | Edits code directly |
|
|
50
|
+
| `reviewer` | Review-and-fix specialist | `openai-codex/gpt-5.5` | Can edit/fix reviewed code |
|
|
51
|
+
| `context-builder` | Requirements/codebase handoff builder | `openai-codex/gpt-5.5` | Writes structured context files |
|
|
52
|
+
| `researcher` | Web research brief generator | `openai-codex/gpt-5.5` | Writes `research.md` |
|
|
53
53
|
| `delegate` | Lightweight generic delegate | inherits parent model | No fixed output; generic delegated work |
|
|
54
|
-
| `oracle` | Decision-consistency advisory review | `openai-codex/gpt-5.
|
|
55
|
-
| `oracle-executor` | Implementation after approval | `openai-codex/gpt-5.
|
|
54
|
+
| `oracle` | Decision-consistency advisory review | `openai-codex/gpt-5.5` | Advisory review, intercom coordination |
|
|
55
|
+
| `oracle-executor` | Implementation after approval | `openai-codex/gpt-5.5` | Single-writer implementation after approval |
|
|
56
56
|
|
|
57
57
|
Override builtin defaults via settings before copying full agent files when a
|
|
58
58
|
small tweak is enough.
|
|
@@ -145,14 +145,13 @@ subagent({
|
|
|
145
145
|
})
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
-
Inspect async runs with
|
|
149
|
-
`/subagents-status` slash command.
|
|
148
|
+
Inspect async runs with `subagent({ action: "status", id: "..." })`, `subagent({ action: "status" })` for active runs, or the `/subagents-status` slash command.
|
|
150
149
|
|
|
151
150
|
### Subagent control
|
|
152
151
|
|
|
153
|
-
Subagent control is the runtime visibility and intervention layer for delegated runs. It is separate from lifecycle status. Lifecycle status says whether a child is `running`, `
|
|
152
|
+
Subagent control is the runtime visibility and intervention layer for delegated runs. It is separate from lifecycle status. Lifecycle status says whether a child is `queued`, `running`, `paused`, `complete`, or `failed`. Activity reporting is factual: it tracks the last observed activity time and the current tool when known. It does not pretend to know that a child is truly stuck.
|
|
154
153
|
|
|
155
|
-
Default behavior is intentionally conservative.
|
|
154
|
+
Default behavior is intentionally conservative. When no activity has been observed past the configured threshold, the run emits a `needs_attention` control event. Foreground runs can push this as a `subagent:control-event` event, and async runs persist it to `events.jsonl` so the parent tracker can surface it without constant manual polling. Notification-worthy control events are also inserted into the visible transcript so both the user and the parent agent can see them, with a proactive hint plus concrete `nudge`, `status`, and `interrupt` options. Visible notifications fire once per child run and attention state.
|
|
156
155
|
|
|
157
156
|
Use soft interrupt when a child is clearly blocked or drifting and the parent needs to regain control:
|
|
158
157
|
|
|
@@ -160,28 +159,29 @@ Use soft interrupt when a child is clearly blocked or drifting and the parent ne
|
|
|
160
159
|
subagent({ action: "interrupt" })
|
|
161
160
|
```
|
|
162
161
|
|
|
163
|
-
Pass `
|
|
162
|
+
Pass `id` when targeting a specific controllable run:
|
|
164
163
|
|
|
165
164
|
```typescript
|
|
166
|
-
subagent({ action: "interrupt",
|
|
165
|
+
subagent({ action: "interrupt", id: "abc123" })
|
|
167
166
|
```
|
|
168
167
|
|
|
169
168
|
A soft interrupt cancels the current child turn and leaves the run paused. It does not mean the delegated task succeeded or failed. After an interrupt, decide the next explicit action: resume with clearer instructions, replace the task, ask the user, or stop the workflow.
|
|
170
169
|
|
|
171
|
-
Per-run control thresholds can be overridden when a task legitimately runs
|
|
170
|
+
Per-run control thresholds can be overridden when a task legitimately runs without observable output for longer than usual:
|
|
172
171
|
|
|
173
172
|
```typescript
|
|
174
173
|
subagent({
|
|
175
174
|
agent: "worker",
|
|
176
175
|
task: "Run the slow migration test suite",
|
|
177
176
|
control: {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
parentMode: "transitions"
|
|
177
|
+
needsAttentionAfterMs: 300000,
|
|
178
|
+
notifyOn: ["needs_attention"]
|
|
181
179
|
}
|
|
182
180
|
})
|
|
183
181
|
```
|
|
184
182
|
|
|
183
|
+
If the run already has an active intercom bridge target, needs-attention notifications can also prepare a compact intercom ping for the orchestrator. When a child route is available, the ping tells the orchestrator which agent needs attention and includes the exact `intercom({ action: "send", to: "..." })` target for a nudge. Do not invent a target or ask the child to self-report when no bridge exists.
|
|
184
|
+
|
|
185
185
|
## Clarify TUI
|
|
186
186
|
|
|
187
187
|
Single and parallel runs support a clarification TUI when you want to preview or
|
|
@@ -369,7 +369,7 @@ particular agent or with forked context.
|
|
|
369
369
|
filtered contexts.
|
|
370
370
|
- **Default subagent nesting depth is 2.** Deeper recursive delegation is blocked
|
|
371
371
|
unless configured otherwise.
|
|
372
|
-
- **
|
|
372
|
+
- **Attention signals are not lifecycle state.** `needs_attention` means no activity has been observed past the configured threshold. `paused` means the child turn was intentionally interrupted or is awaiting direction; it is not the same as `failed`.
|
|
373
373
|
- **Intercom asks are blocking.** A session can only maintain one pending outbound
|
|
374
374
|
ask wait state at a time.
|
|
375
375
|
- **Keep conversational authority clear.** Advisory subagents should not silently
|
|
@@ -400,7 +400,7 @@ it should coordinate back via `intercom` instead of deciding alone.
|
|
|
400
400
|
|
|
401
401
|
### Intervene only on clear control signals
|
|
402
402
|
|
|
403
|
-
Use subagent control proactively when a delegated run
|
|
403
|
+
Use subagent control proactively when a delegated run emits `needs_attention`, or when a human asks you to regain control. Do not interrupt just because a child has briefly produced no output. Silence can be normal during long tool calls, test runs, or model reasoning.
|
|
404
404
|
|
|
405
405
|
### Name sessions meaningfully
|
|
406
406
|
|
package/slash-live-state.ts
CHANGED
|
@@ -63,7 +63,6 @@ function createPlaceholderResult(
|
|
|
63
63
|
...(index !== undefined ? { index } : {}),
|
|
64
64
|
agent,
|
|
65
65
|
status,
|
|
66
|
-
activityState: status === "running" ? "starting" : undefined,
|
|
67
66
|
task,
|
|
68
67
|
recentTools: [],
|
|
69
68
|
recentOutput: [],
|
|
@@ -86,7 +85,6 @@ function buildParallelInitialResult(params: SubagentParamsLike): AgentToolResult
|
|
|
86
85
|
index,
|
|
87
86
|
agent: task.agent,
|
|
88
87
|
status: "running" as const,
|
|
89
|
-
activityState: "starting" as const,
|
|
90
88
|
task: task.task,
|
|
91
89
|
recentTools: [],
|
|
92
90
|
recentOutput: [],
|
|
@@ -142,7 +140,6 @@ function buildChainInitialResult(params: SubagentParamsLike): AgentToolResult<De
|
|
|
142
140
|
index,
|
|
143
141
|
agent: result.agent,
|
|
144
142
|
status: index === 0 ? "running" as const : "pending" as const,
|
|
145
|
-
activityState: index === 0 ? "starting" as const : undefined,
|
|
146
143
|
task: result.task,
|
|
147
144
|
recentTools: [],
|
|
148
145
|
recentOutput: [],
|
|
@@ -169,7 +166,6 @@ function buildSingleInitialResult(params: SubagentParamsLike): AgentToolResult<D
|
|
|
169
166
|
progress: [{
|
|
170
167
|
agent,
|
|
171
168
|
status: "running",
|
|
172
|
-
activityState: "starting",
|
|
173
169
|
task,
|
|
174
170
|
recentTools: [],
|
|
175
171
|
recentOutput: [],
|
package/subagent-control.ts
CHANGED
|
@@ -3,14 +3,19 @@ import {
|
|
|
3
3
|
type ControlConfig,
|
|
4
4
|
type ControlEvent,
|
|
5
5
|
type ControlEventType,
|
|
6
|
+
type ControlNotificationChannel,
|
|
6
7
|
type ResolvedControlConfig,
|
|
7
8
|
} from "./types.ts";
|
|
8
9
|
|
|
10
|
+
const CONTROL_EVENT_TYPES: ControlEventType[] = ["needs_attention"];
|
|
11
|
+
const CONTROL_NOTIFICATION_CHANNELS: ControlNotificationChannel[] = ["event", "async", "intercom"];
|
|
12
|
+
const DEFAULT_NOTIFY_ON: ControlEventType[] = ["needs_attention"];
|
|
13
|
+
|
|
9
14
|
export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
|
|
10
15
|
enabled: true,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
needsAttentionAfterMs: 60_000,
|
|
17
|
+
notifyOn: DEFAULT_NOTIFY_ON,
|
|
18
|
+
notifyChannels: CONTROL_NOTIFICATION_CHANNELS,
|
|
14
19
|
};
|
|
15
20
|
|
|
16
21
|
function parsePositiveInt(value: unknown): number | undefined {
|
|
@@ -19,24 +24,33 @@ function parsePositiveInt(value: unknown): number | undefined {
|
|
|
19
24
|
return value;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
function parseControlList<T extends string>(value: unknown, allowed: readonly T[]): T[] | undefined {
|
|
28
|
+
if (!Array.isArray(value)) return undefined;
|
|
29
|
+
if (value.length === 0) return [];
|
|
30
|
+
const allowedSet = new Set(allowed);
|
|
31
|
+
const parsed = value.filter((entry): entry is T => typeof entry === "string" && allowedSet.has(entry as T));
|
|
32
|
+
return parsed.length > 0 ? Array.from(new Set(parsed)) : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export function resolveControlConfig(
|
|
23
36
|
globalConfig?: ControlConfig,
|
|
24
37
|
override?: ControlConfig,
|
|
25
38
|
): ResolvedControlConfig {
|
|
26
39
|
const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
|
|
27
|
-
const
|
|
28
|
-
?? parsePositiveInt(globalConfig?.
|
|
29
|
-
?? DEFAULT_CONTROL_CONFIG.
|
|
30
|
-
const
|
|
31
|
-
??
|
|
32
|
-
?? DEFAULT_CONTROL_CONFIG.
|
|
33
|
-
const
|
|
34
|
-
|
|
40
|
+
const needsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs)
|
|
41
|
+
?? parsePositiveInt(globalConfig?.needsAttentionAfterMs)
|
|
42
|
+
?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
|
|
43
|
+
const notifyOn = parseControlList(override?.notifyOn, CONTROL_EVENT_TYPES)
|
|
44
|
+
?? parseControlList(globalConfig?.notifyOn, CONTROL_EVENT_TYPES)
|
|
45
|
+
?? DEFAULT_CONTROL_CONFIG.notifyOn;
|
|
46
|
+
const notifyChannels = parseControlList(override?.notifyChannels, CONTROL_NOTIFICATION_CHANNELS)
|
|
47
|
+
?? parseControlList(globalConfig?.notifyChannels, CONTROL_NOTIFICATION_CHANNELS)
|
|
48
|
+
?? DEFAULT_CONTROL_CONFIG.notifyChannels;
|
|
35
49
|
return {
|
|
36
50
|
enabled,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
needsAttentionAfterMs,
|
|
52
|
+
notifyOn: [...notifyOn],
|
|
53
|
+
notifyChannels: [...notifyChannels],
|
|
40
54
|
};
|
|
41
55
|
}
|
|
42
56
|
|
|
@@ -44,57 +58,40 @@ export function deriveActivityState(input: {
|
|
|
44
58
|
config: ResolvedControlConfig;
|
|
45
59
|
startedAt: number;
|
|
46
60
|
lastActivityAt?: number;
|
|
47
|
-
hasSeenActivity: boolean;
|
|
48
|
-
paused: boolean;
|
|
49
61
|
now?: number;
|
|
50
62
|
}): ActivityState | undefined {
|
|
51
63
|
if (!input.config.enabled) return undefined;
|
|
52
|
-
if (input.paused) return "paused";
|
|
53
|
-
if (!input.hasSeenActivity) return "starting";
|
|
54
64
|
const now = input.now ?? Date.now();
|
|
55
65
|
const lastActivity = input.lastActivityAt ?? input.startedAt;
|
|
56
66
|
const ageMs = Math.max(0, now - lastActivity);
|
|
57
|
-
|
|
58
|
-
if (ageMs <= input.config.stalledAfterMs) return "quiet";
|
|
59
|
-
return "stalled";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function controlEventType(from: ActivityState | undefined, to: ActivityState): ControlEventType {
|
|
63
|
-
if (to === "stalled") return "stalled";
|
|
64
|
-
if (to === "paused") return "paused";
|
|
65
|
-
if (from === "stalled" && to !== "stalled") return "recovered";
|
|
66
|
-
if (from === "paused" && to !== "paused") return "resumed";
|
|
67
|
-
return "activity";
|
|
67
|
+
return ageMs > input.config.needsAttentionAfterMs ? "needs_attention" : undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function shouldEmitControlEvent(
|
|
71
71
|
config: ResolvedControlConfig,
|
|
72
72
|
from: ActivityState | undefined,
|
|
73
|
-
to: ActivityState,
|
|
73
|
+
to: ActivityState | undefined,
|
|
74
74
|
): boolean {
|
|
75
|
-
|
|
76
|
-
if (config.parentMode === "verbose") return true;
|
|
77
|
-
if (to === "stalled" || to === "paused") return true;
|
|
78
|
-
if (from === "stalled" && to !== "stalled") return true;
|
|
79
|
-
if (from === "paused" && to !== "paused") return true;
|
|
80
|
-
return false;
|
|
75
|
+
return config.enabled && from !== to && to === "needs_attention";
|
|
81
76
|
}
|
|
82
77
|
|
|
83
78
|
export function buildControlEvent(input: {
|
|
84
|
-
from
|
|
79
|
+
from?: ActivityState;
|
|
85
80
|
to: ActivityState;
|
|
86
81
|
runId: string;
|
|
87
82
|
agent: string;
|
|
88
83
|
index?: number;
|
|
89
84
|
ts?: number;
|
|
85
|
+
lastActivityAt?: number;
|
|
90
86
|
}): ControlEvent {
|
|
91
87
|
const ts = input.ts ?? Date.now();
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
const elapsedMs = input.lastActivityAt ? Math.max(0, ts - input.lastActivityAt) : undefined;
|
|
89
|
+
const elapsedSeconds = elapsedMs !== undefined ? Math.floor(elapsedMs / 1000) : undefined;
|
|
90
|
+
const message = elapsedSeconds !== undefined
|
|
91
|
+
? `${input.agent} needs attention (no observed activity for ${elapsedSeconds}s)`
|
|
92
|
+
: `${input.agent} needs attention`;
|
|
96
93
|
return {
|
|
97
|
-
type,
|
|
94
|
+
type: "needs_attention",
|
|
98
95
|
from: input.from,
|
|
99
96
|
to: input.to,
|
|
100
97
|
ts,
|
|
@@ -104,3 +101,48 @@ export function buildControlEvent(input: {
|
|
|
104
101
|
message,
|
|
105
102
|
};
|
|
106
103
|
}
|
|
104
|
+
|
|
105
|
+
export function shouldNotifyControlEvent(config: ResolvedControlConfig, event: ControlEvent): boolean {
|
|
106
|
+
return config.enabled && config.notifyOn.includes(event.type);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function controlNotificationKey(event: ControlEvent, childIntercomTarget?: string): string {
|
|
110
|
+
const childKey = childIntercomTarget ?? (event.index !== undefined ? `${event.runId}:${event.index}` : event.runId);
|
|
111
|
+
return `${childKey}:${event.type}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function claimControlNotification(config: ResolvedControlConfig, event: ControlEvent, seenKeys: Set<string>, childIntercomTarget?: string): boolean {
|
|
115
|
+
if (!shouldNotifyControlEvent(config, event)) return false;
|
|
116
|
+
const key = controlNotificationKey(event, childIntercomTarget);
|
|
117
|
+
if (seenKeys.has(key)) return false;
|
|
118
|
+
seenKeys.add(key);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function formatControlNoticeMessage(event: ControlEvent, childIntercomTarget?: string): string {
|
|
123
|
+
const runTarget = event.runId;
|
|
124
|
+
const nudgeCommand = childIntercomTarget
|
|
125
|
+
? `intercom({ action: "send", to: "${childIntercomTarget}", message: "What are you blocked on? Reply with the smallest next step or ask for a decision." })`
|
|
126
|
+
: undefined;
|
|
127
|
+
return [
|
|
128
|
+
`Subagent needs attention: ${event.agent}`,
|
|
129
|
+
`Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
|
|
130
|
+
`Signal: ${event.message}`,
|
|
131
|
+
"Hint: Inspect status first unless the run is clearly blocked.",
|
|
132
|
+
childIntercomTarget
|
|
133
|
+
? `Nudge: ${nudgeCommand}`
|
|
134
|
+
: "Nudge: no child message route registered",
|
|
135
|
+
`Status: subagent({ action: "status", id: "${runTarget}" })`,
|
|
136
|
+
`Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatControlIntercomMessage(event: ControlEvent, childIntercomTarget?: string): string {
|
|
141
|
+
return [
|
|
142
|
+
"subagent needs attention",
|
|
143
|
+
"",
|
|
144
|
+
`${event.agent} needs attention in run ${event.runId}.`,
|
|
145
|
+
"",
|
|
146
|
+
formatControlNoticeMessage(event, childIntercomTarget),
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|