pi-famulus 0.2.0 → 0.2.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +8 -8
- package/src/bounded-output.ts +17 -1
- package/src/format.ts +4 -0
- package/src/index.ts +271 -7
- package/src/intercom/intercom.ts +1266 -0
- package/src/intercom/mention.ts +83 -0
- package/src/intercom/overlay.ts +439 -0
- package/src/intercom/transport.ts +102 -0
- package/src/intercom/types.ts +39 -0
- package/src/intercom/widgets.ts +130 -0
- package/src/manager-client.ts +29 -4
- package/src/notify.ts +1 -0
- package/src/prompts.generated.ts +1 -1
- package/src/reload-handoff.ts +8 -2
- package/src/subagent/agent-records.ts +51 -2
- package/src/subagent/hosted-session.ts +57 -11
- package/src/subagent/registry.ts +78 -0
- package/src/subagent/runner.ts +75 -8
- package/src/subagent/tool.ts +10 -1
- package/src/subagent/types.ts +8 -1
- package/src/work-index.ts +6 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI widgets for intercom message cards.
|
|
3
|
+
*
|
|
4
|
+
* Upstream baseline: agent-intercom @ 98183fb, plugin/pi — verbatim port of
|
|
5
|
+
* the plugin's ChatText/ChatCard renderers and text-measurement helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Rough display width: CJK ideographs and wide chars count as 2. */
|
|
9
|
+
export function stringWidth(s: string): number {
|
|
10
|
+
let w = 0;
|
|
11
|
+
for (const ch of s) {
|
|
12
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
13
|
+
w += cp >= 0x2e80 ? 2 : 1;
|
|
14
|
+
}
|
|
15
|
+
return w;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Word-wrap one line, treating ANSI SGR sequences as zero-width. */
|
|
19
|
+
export function wrapAnsiLine(line: string, width: number): string[] {
|
|
20
|
+
if (stringWidth(line.replace(/\x1b\[[0-9;]*m/g, "")) <= width) return [line];
|
|
21
|
+
const tokens = line.match(/(\x1b\[[0-9;]*m|\s+|[^\s\x1b]+)/g) ?? [line];
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
let cur = "";
|
|
24
|
+
let curWidth = 0;
|
|
25
|
+
const push = () => {
|
|
26
|
+
lines.push(cur.trimEnd());
|
|
27
|
+
cur = "";
|
|
28
|
+
curWidth = 0;
|
|
29
|
+
};
|
|
30
|
+
for (const token of tokens) {
|
|
31
|
+
if (token.startsWith("\x1b")) {
|
|
32
|
+
cur += token;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (/^\s+$/.test(token)) {
|
|
36
|
+
if (cur && curWidth + 1 <= width) {
|
|
37
|
+
cur += " ";
|
|
38
|
+
curWidth += 1;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
let piece = token;
|
|
43
|
+
let pieceWidth = stringWidth(piece);
|
|
44
|
+
if (curWidth > 0 && curWidth + pieceWidth > width) push();
|
|
45
|
+
while (pieceWidth > width) {
|
|
46
|
+
let cut = 0;
|
|
47
|
+
let cutWidth = 0;
|
|
48
|
+
for (const ch of piece) {
|
|
49
|
+
const w = stringWidth(ch);
|
|
50
|
+
if (cutWidth + w > width) break;
|
|
51
|
+
cutWidth += w;
|
|
52
|
+
cut += ch.length;
|
|
53
|
+
}
|
|
54
|
+
cur += piece.slice(0, cut);
|
|
55
|
+
push();
|
|
56
|
+
piece = piece.slice(cut);
|
|
57
|
+
pieceWidth = stringWidth(piece);
|
|
58
|
+
}
|
|
59
|
+
cur += piece;
|
|
60
|
+
curWidth += pieceWidth;
|
|
61
|
+
}
|
|
62
|
+
if (cur.trimEnd()) push();
|
|
63
|
+
return lines.length ? lines : [""];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Local wall-clock `YYYY-mm-dd HH:ss`. */
|
|
67
|
+
export function formatTimestamp(d: Date): string {
|
|
68
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
69
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal Text component satisfying pi's Component contract; no pi-tui dependency. */
|
|
73
|
+
export class ChatText {
|
|
74
|
+
private text: string;
|
|
75
|
+
private paddingX: number;
|
|
76
|
+
|
|
77
|
+
constructor(text: string, paddingX = 1) {
|
|
78
|
+
this.text = text;
|
|
79
|
+
this.paddingX = paddingX;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
invalidate(): void {}
|
|
83
|
+
|
|
84
|
+
render(width: number): string[] {
|
|
85
|
+
const padding = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2)));
|
|
86
|
+
const pad = " ".repeat(padding);
|
|
87
|
+
const contentWidth = Math.max(1, width - padding * 2);
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
for (const line of this.text.split("\n")) {
|
|
90
|
+
for (const wrapped of wrapAnsiLine(line, contentWidth)) {
|
|
91
|
+
out.push(pad + wrapped);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Boxed card with a full-width background, matching pi's custom message look. */
|
|
99
|
+
export class ChatCard {
|
|
100
|
+
private header: string;
|
|
101
|
+
private body: string;
|
|
102
|
+
private bg: (text: string) => string;
|
|
103
|
+
|
|
104
|
+
constructor(header: string, body: string, bg: (text: string) => string) {
|
|
105
|
+
this.header = header;
|
|
106
|
+
this.body = body;
|
|
107
|
+
this.bg = bg;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
invalidate(): void {}
|
|
111
|
+
|
|
112
|
+
render(width: number): string[] {
|
|
113
|
+
const px = 1;
|
|
114
|
+
const contentWidth = Math.max(1, width - px * 2);
|
|
115
|
+
const out: string[] = [this.bg(" ".repeat(width))];
|
|
116
|
+
const padLine = (s: string) => {
|
|
117
|
+
const visible = stringWidth(s.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
118
|
+
return this.bg(" ".repeat(px) + s + " ".repeat(Math.max(0, width - px - visible)));
|
|
119
|
+
};
|
|
120
|
+
for (const line of `${this.header}\n${this.body}`.split("\n")) {
|
|
121
|
+
for (const wrapped of wrapAnsiLine(line, contentWidth)) {
|
|
122
|
+
out.push(padLine(wrapped));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
out.push(this.bg(" ".repeat(width)));
|
|
126
|
+
// One transparent line below the card as extra bottom margin.
|
|
127
|
+
out.push("");
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/manager-client.ts
CHANGED
|
@@ -18,10 +18,11 @@ import { famulusPaths } from "./config";
|
|
|
18
18
|
import { realClock, type Clock, type ClockTimer } from "./clock";
|
|
19
19
|
|
|
20
20
|
const MAX_FRAME_BYTES = 4 * 1024 * 1024; // 4 MiB (§3.3)
|
|
21
|
-
const EXTENSION_VERSION = "0.2.0";
|
|
21
|
+
const EXTENSION_VERSION = "0.2.2-beta.0";
|
|
22
22
|
export { EXTENSION_VERSION };
|
|
23
|
-
//
|
|
24
|
-
|
|
23
|
+
// 6: hosted subagents. 7: the extension's messaging bridge (`intercom`
|
|
24
|
+
// request plus `intercom`/`intercom_reset` events).
|
|
25
|
+
const PROTOCOL = 7;
|
|
25
26
|
const HELLO_TIMEOUT_MS = 5000;
|
|
26
27
|
const RECONNECT_HELLO_TIMEOUT_MS = 25_000;
|
|
27
28
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
@@ -108,7 +109,14 @@ export interface MonitorOutputRecord {
|
|
|
108
109
|
|
|
109
110
|
/** Server-pushed event (§3.3). Fields beyond `event` depend on the event kind. */
|
|
110
111
|
export interface ManagerEvent {
|
|
111
|
-
event:
|
|
112
|
+
event:
|
|
113
|
+
| "task_started"
|
|
114
|
+
| "output"
|
|
115
|
+
| "task_exited"
|
|
116
|
+
| "session_rebound"
|
|
117
|
+
| "intercom"
|
|
118
|
+
| "intercom_reset"
|
|
119
|
+
| string;
|
|
112
120
|
task_id?: string;
|
|
113
121
|
kind?: string;
|
|
114
122
|
command?: string;
|
|
@@ -126,6 +134,8 @@ export interface ManagerEvent {
|
|
|
126
134
|
ts?: number;
|
|
127
135
|
/** Set on task_started when the daemon recorded who asked for the task. */
|
|
128
136
|
origin?: { via: string; child_id?: string; run_id?: string };
|
|
137
|
+
/** An intercom bridge frame (deliver/receipt/idle_notice) for this session. */
|
|
138
|
+
frame?: Record<string, unknown>;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
export class ManagerError extends Error {
|
|
@@ -608,6 +618,19 @@ export class ManagerClient {
|
|
|
608
618
|
});
|
|
609
619
|
}
|
|
610
620
|
|
|
621
|
+
/** The extension's messaging bridge: one intercom client-protocol op
|
|
622
|
+
* (register/send/list/receipt/…). Protocol 7+; rejected by older managers. */
|
|
623
|
+
async intercom(op: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
624
|
+
if (this.protocolLevel_ < 7) {
|
|
625
|
+
throw new ManagerError(
|
|
626
|
+
"E_VERSION",
|
|
627
|
+
"this pi-famulus daemon does not serve cross-session messaging (protocol " +
|
|
628
|
+
`${this.protocolLevel_}; need 7). Restart pi so it starts the current daemon.`,
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return this.request({ type: "intercom", op });
|
|
632
|
+
}
|
|
633
|
+
|
|
611
634
|
/** Daemon status, including optional in-place upgrade metadata. */
|
|
612
635
|
async status(): Promise<ManagerStatusResponse> {
|
|
613
636
|
const res = await this.request({ type: "status" });
|
|
@@ -637,6 +660,8 @@ export class ManagerClient {
|
|
|
637
660
|
async close(): Promise<void> {
|
|
638
661
|
this.intentionalClose = true;
|
|
639
662
|
this.state = "disconnected";
|
|
663
|
+
this.eventHandlers.clear();
|
|
664
|
+
this.reconnectHandlers.clear();
|
|
640
665
|
this.failAllPending(new Error("manager client closed"));
|
|
641
666
|
const socket = this.socket;
|
|
642
667
|
this.socket = null;
|
package/src/notify.ts
CHANGED
package/src/prompts.generated.ts
CHANGED
|
@@ -49,7 +49,7 @@ export const PROMPTS = {
|
|
|
49
49
|
"tools.subagent.rules": ["When a <pi-famulus-wake kind=\"subagent-handover\"> arrives, read <prompt> and <result> immediately and continue: subagent({action:\"resume\", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.","Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.","A failed subagent does not fail the whole run; inspect per-subagent sections in the result.","<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run."],
|
|
50
50
|
"tools.subagent.param.tasks": "Start new subagents in parallel. Use only when action is omitted. For any action call, omit tasks entirely; do not supply placeholder tasks.",
|
|
51
51
|
"tools.subagent.param.tasks.agent": "Agent definition name (default: worker)",
|
|
52
|
-
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent",
|
|
52
|
+
"tools.subagent.param.tasks.prompt": "Task prompt for this subagent. The child is a new process: it cannot see this conversation, so the prompt must be self-contained.",
|
|
53
53
|
"tools.subagent.param.tasks.name": "Display name (default: agent name + ordinal)",
|
|
54
54
|
"tools.subagent.param.work_kind": "Explicit planned workload class for machine-wide admission (default: other)",
|
|
55
55
|
"tools.subagent.param.chain": "Start a new sequential chain (always awaited). Use only when action is omitted. For any action call, omit chain entirely.",
|
package/src/reload-handoff.ts
CHANGED
|
@@ -29,6 +29,7 @@ const REGISTRATIONS = new Set<PropertyKey>([
|
|
|
29
29
|
"registerShortcut",
|
|
30
30
|
"registerFlag",
|
|
31
31
|
"registerMessageRenderer",
|
|
32
|
+
"registerEntryRenderer",
|
|
32
33
|
"registerProvider",
|
|
33
34
|
]);
|
|
34
35
|
|
|
@@ -51,8 +52,13 @@ export function createPiBinding(): PiBinding {
|
|
|
51
52
|
let current: ExtensionAPI | null = null;
|
|
52
53
|
let live = false;
|
|
53
54
|
|
|
54
|
-
const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown =>
|
|
55
|
-
|
|
55
|
+
const call = (pi: ExtensionAPI, method: PropertyKey, args: unknown[]): unknown => {
|
|
56
|
+
const fn = Reflect.get(pi, method) as unknown;
|
|
57
|
+
// A host surface may not implement every registration kind (hosted
|
|
58
|
+
// child fakes, older pi); skipping beats crashing the replay.
|
|
59
|
+
if (typeof fn !== "function") return undefined;
|
|
60
|
+
return (fn as (...a: unknown[]) => unknown).apply(pi, args);
|
|
61
|
+
};
|
|
56
62
|
|
|
57
63
|
const api = new Proxy({} as ExtensionAPI, {
|
|
58
64
|
get(_target, key) {
|
|
@@ -32,6 +32,8 @@ export interface AgentChildRecord {
|
|
|
32
32
|
error?: string;
|
|
33
33
|
/** Why the child ended (terminal statuses only). */
|
|
34
34
|
end_reason?: AgentEndReason;
|
|
35
|
+
/** Seconds until a rate-limited provider may be retried, when known. */
|
|
36
|
+
retry_after?: number;
|
|
35
37
|
/** Task prompt without the agent preamble, capped. */
|
|
36
38
|
prompt_head?: string;
|
|
37
39
|
/** Tail of the final result text, capped. */
|
|
@@ -58,6 +60,7 @@ export type AgentEndReason =
|
|
|
58
60
|
| "completed"
|
|
59
61
|
| "failed"
|
|
60
62
|
| "model-error"
|
|
63
|
+
| "rate-limit"
|
|
61
64
|
| "stalled"
|
|
62
65
|
| "timeout"
|
|
63
66
|
| "interrupted"
|
|
@@ -66,10 +69,11 @@ export type AgentEndReason =
|
|
|
66
69
|
/** Map a terminal child status + result to the end_reason recorded on disk. */
|
|
67
70
|
export function agentEndReason(
|
|
68
71
|
status: AgentChildRecord["status"],
|
|
69
|
-
result: { error?: string; endReason?: "model-error" } | undefined,
|
|
72
|
+
result: { error?: string; endReason?: "model-error" | "rate-limit" } | undefined,
|
|
70
73
|
): AgentEndReason | undefined {
|
|
71
74
|
if (status === "completed") return "completed";
|
|
72
75
|
if (status === "failed") {
|
|
76
|
+
if (result?.endReason === "rate-limit") return "rate-limit";
|
|
73
77
|
if (result?.endReason === "model-error") return "model-error";
|
|
74
78
|
return result?.error === "stalled" ? "stalled" : "failed";
|
|
75
79
|
}
|
|
@@ -174,6 +178,42 @@ function parseRecord(raw: string): AgentChildRecord | null {
|
|
|
174
178
|
}
|
|
175
179
|
|
|
176
180
|
/** Load agent child records from disk (all sessions under home). */
|
|
181
|
+
/** Hosted children the daemon still has warm. They outlive a gone parent session. */
|
|
182
|
+
export function warmHostedChildIds(home: string): Set<string> {
|
|
183
|
+
const out = new Set<string>();
|
|
184
|
+
try {
|
|
185
|
+
const { pid } = JSON.parse(readFileSync(join(home, "manager.pid"), "utf8")) as { pid?: unknown };
|
|
186
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return out;
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 0);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if ((error as NodeJS.ErrnoException).code !== "EPERM") return out;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
let files: string[];
|
|
196
|
+
try {
|
|
197
|
+
files = readdirSync(join(home, "agents")).filter((name) => name.endsWith(".json"));
|
|
198
|
+
} catch {
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
try {
|
|
203
|
+
const value = JSON.parse(readFileSync(join(home, "agents", file), "utf8")) as {
|
|
204
|
+
residency?: string;
|
|
205
|
+
child_id?: string;
|
|
206
|
+
};
|
|
207
|
+
if (value.residency === "warm" && typeof value.child_id === "string") {
|
|
208
|
+
out.add(value.child_id);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// skip bad hosted records
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
177
217
|
export function loadAgentChildRecords(
|
|
178
218
|
home: string,
|
|
179
219
|
opts: { sessionId?: string; includeTerminal?: boolean; connectedSessionIds?: ReadonlySet<string> } = {},
|
|
@@ -189,6 +229,7 @@ export function loadAgentChildRecords(
|
|
|
189
229
|
} catch {
|
|
190
230
|
return [];
|
|
191
231
|
}
|
|
232
|
+
const warm = opts.connectedSessionIds === undefined ? new Set<string>() : warmHostedChildIds(home);
|
|
192
233
|
const out: AgentChildRecord[] = [];
|
|
193
234
|
for (const sid of sessionIds) {
|
|
194
235
|
const dir = agentRecordsDir(home, sid);
|
|
@@ -205,7 +246,9 @@ export function loadAgentChildRecords(
|
|
|
205
246
|
const disconnected =
|
|
206
247
|
opts.connectedSessionIds !== undefined && !opts.connectedSessionIds.has(rec.session_id);
|
|
207
248
|
const shown: AgentChildRecord =
|
|
208
|
-
disconnected &&
|
|
249
|
+
disconnected &&
|
|
250
|
+
(rec.status === "pending" || rec.status === "running") &&
|
|
251
|
+
!warm.has(rec.child_id)
|
|
209
252
|
? { ...rec, status: "interrupted" }
|
|
210
253
|
: rec;
|
|
211
254
|
const terminal =
|
|
@@ -220,6 +263,12 @@ export function loadAgentChildRecords(
|
|
|
220
263
|
return out;
|
|
221
264
|
}
|
|
222
265
|
|
|
266
|
+
/** One child by id or name, across every session under home. */
|
|
267
|
+
export function findAgentChildRecord(home: string, childIdOrName: string): AgentChildRecord | undefined {
|
|
268
|
+
const recs = loadAgentChildRecords(home, { includeTerminal: true });
|
|
269
|
+
return recs.find((rec) => rec.child_id === childIdOrName) ?? recs.find((rec) => rec.name === childIdOrName);
|
|
270
|
+
}
|
|
271
|
+
|
|
223
272
|
export function isAgentStatusActive(status: AgentChildRecord["status"]): boolean {
|
|
224
273
|
return status === "pending" || status === "running";
|
|
225
274
|
}
|
|
@@ -66,10 +66,10 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
|
|
|
66
66
|
"--thinking", String(thinking),
|
|
67
67
|
"--append-system-prompt", promptFile,
|
|
68
68
|
...(trustInherited ? [] : [deps.getProjectTrusted?.() ? "-a" : "-na"]),
|
|
69
|
-
...(req.agent.tools ? ["--tools", req.agent.tools.join(",")] : []),
|
|
69
|
+
...(req.agent.tools ? ["--tools", hostedChildTools(req.agent.tools).join(",")] : []),
|
|
70
70
|
];
|
|
71
71
|
const env = { ...process.env } as Record<string, string>;
|
|
72
|
-
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = req.agent.tools.join(",");
|
|
72
|
+
if (req.agent.tools) env.PI_FAMULUS_CHILD_TOOLS = hostedChildTools(req.agent.tools).join(",");
|
|
73
73
|
env.PI_FAMULUS_CHILD_RUN = req.runId;
|
|
74
74
|
env.PI_FAMULUS_CHILD_NAME = req.name;
|
|
75
75
|
const spawned = await client.agentSpawn({
|
|
@@ -80,14 +80,7 @@ export function createHostedSessionFn(deps: HostedSessionDeps): CreateSessionFn
|
|
|
80
80
|
cwd,
|
|
81
81
|
env,
|
|
82
82
|
});
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
await client.agentInput(req.childId, "set_steering_mode", "all", "parent");
|
|
86
|
-
} catch (err) {
|
|
87
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
-
const note = `Steering mode was not set (${message}); queued input may wait until the current turn ends.`;
|
|
89
|
-
warning = warning ? `${warning} ${note}` : note;
|
|
90
|
-
}
|
|
83
|
+
const warning = model.warning;
|
|
91
84
|
await client.agentWatch(req.childId);
|
|
92
85
|
const session = new HostedRpcSession(client, req.childId, {
|
|
93
86
|
warning,
|
|
@@ -108,6 +101,7 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
108
101
|
private readonly unsubscribe: () => void;
|
|
109
102
|
private streaming = false;
|
|
110
103
|
private lastText = "";
|
|
104
|
+
private lastFailure: { stopReason: "error" | "aborted"; errorMessage?: string } | undefined;
|
|
111
105
|
private turns: { role: string; text: string }[] = [];
|
|
112
106
|
private usage: ChildTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
113
107
|
private settle: (() => void) | null = null;
|
|
@@ -172,6 +166,10 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
172
166
|
return this.lastText || undefined;
|
|
173
167
|
}
|
|
174
168
|
|
|
169
|
+
getLastAssistantFailure(): { stopReason: "error" | "aborted"; errorMessage?: string } | undefined {
|
|
170
|
+
return this.lastFailure;
|
|
171
|
+
}
|
|
172
|
+
|
|
175
173
|
getConversation() {
|
|
176
174
|
return this.turns.map((turn) => ({ ...turn }));
|
|
177
175
|
}
|
|
@@ -216,6 +214,9 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
216
214
|
};
|
|
217
215
|
if (event.activity === "running") this.streaming = true;
|
|
218
216
|
if (event.activity === "idle" || event.activity === "failed") {
|
|
217
|
+
if (event.activity === "failed" && !this.lastFailure) {
|
|
218
|
+
this.lastFailure = { stopReason: "error", errorMessage: "hosted child process failed" };
|
|
219
|
+
}
|
|
219
220
|
this.streaming = false;
|
|
220
221
|
this.settle?.();
|
|
221
222
|
this.settle = null;
|
|
@@ -223,7 +224,20 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
223
224
|
return;
|
|
224
225
|
}
|
|
225
226
|
if (event.event !== "agent_rpc" || !event.record || typeof event.record !== "object") return;
|
|
226
|
-
const record = event.record as {
|
|
227
|
+
const record = event.record as {
|
|
228
|
+
type?: string;
|
|
229
|
+
message?: {
|
|
230
|
+
role?: string;
|
|
231
|
+
content?: { type?: string; text?: string }[];
|
|
232
|
+
usage?: Record<string, unknown>;
|
|
233
|
+
stopReason?: string;
|
|
234
|
+
errorMessage?: string;
|
|
235
|
+
};
|
|
236
|
+
toolCallId?: string;
|
|
237
|
+
toolName?: string;
|
|
238
|
+
args?: unknown;
|
|
239
|
+
isError?: boolean;
|
|
240
|
+
};
|
|
227
241
|
const type = record.type ?? "";
|
|
228
242
|
if (type === "agent_start") this.streaming = true;
|
|
229
243
|
if (type === "agent_settled") {
|
|
@@ -237,6 +251,18 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
237
251
|
this.lastText = text;
|
|
238
252
|
this.turns.push({ role: "assistant", text });
|
|
239
253
|
}
|
|
254
|
+
const stopReason = record.message.stopReason;
|
|
255
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
256
|
+
this.lastFailure = {
|
|
257
|
+
stopReason,
|
|
258
|
+
...(record.message.errorMessage ? { errorMessage: record.message.errorMessage } : {}),
|
|
259
|
+
};
|
|
260
|
+
if (record.message.errorMessage) {
|
|
261
|
+
this.turns.push({ role: "assistant error", text: record.message.errorMessage });
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
this.lastFailure = undefined;
|
|
265
|
+
}
|
|
240
266
|
const usage = record.message.usage;
|
|
241
267
|
if (usage) {
|
|
242
268
|
this.usage.input += num(usage.input);
|
|
@@ -249,10 +275,21 @@ class HostedRpcSession implements ChildSessionAdapter {
|
|
|
249
275
|
const text = (record.message.content ?? []).map((part) => part.text ?? "").join("");
|
|
250
276
|
if (text) this.turns.push({ role: "user", text });
|
|
251
277
|
}
|
|
278
|
+
if (type === "tool_execution_end") {
|
|
279
|
+
const name = record.toolName ?? "tool";
|
|
280
|
+
const text = record.args === undefined ? name : `${name} ${JSON.stringify(record.args)}`;
|
|
281
|
+
this.turns.push({
|
|
282
|
+
role: record.isError ? `tool ${name} error` : `tool ${name}`,
|
|
283
|
+
text,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
252
286
|
const adapted: ChildSessionEvent = {
|
|
253
287
|
type,
|
|
254
288
|
...(record.message?.role ? { role: record.message.role } : {}),
|
|
255
289
|
...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
|
|
290
|
+
...(record.toolName ? { toolName: record.toolName } : {}),
|
|
291
|
+
...(record.args !== undefined ? { args: record.args } : {}),
|
|
292
|
+
...(record.isError !== undefined ? { isError: record.isError } : {}),
|
|
256
293
|
};
|
|
257
294
|
for (const listener of this.listeners) listener(adapted);
|
|
258
295
|
}
|
|
@@ -262,6 +299,15 @@ function num(value: unknown): number {
|
|
|
262
299
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
263
300
|
}
|
|
264
301
|
|
|
302
|
+
const SUPERVISOR_TOOLS = ["contact_supervisor", "agent_message"];
|
|
303
|
+
|
|
304
|
+
/** Agent allowlists still keep the two supervisor tools. */
|
|
305
|
+
export function hostedChildTools(tools: string[]): string[] {
|
|
306
|
+
const kept = new Set(tools);
|
|
307
|
+
for (const name of SUPERVISOR_TOOLS) kept.add(name);
|
|
308
|
+
return [...kept];
|
|
309
|
+
}
|
|
310
|
+
|
|
265
311
|
/** Request id for a child→parent decision. */
|
|
266
312
|
export function newCommsRequestId(): string {
|
|
267
313
|
return `rq_${randomUUID().slice(0, 8)}`;
|
package/src/subagent/registry.ts
CHANGED
|
@@ -342,6 +342,84 @@ export class SubagentRegistry implements RunRegistry {
|
|
|
342
342
|
// M3 extensions beyond Appendix B
|
|
343
343
|
// -------------------------------------------------------------------------
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Reconstruct a run/child the previous parent process wrote to disk
|
|
347
|
+
* so resume can reattach by child_id.
|
|
348
|
+
*/
|
|
349
|
+
adoptExisting(info: {
|
|
350
|
+
runId: string;
|
|
351
|
+
childId: string;
|
|
352
|
+
name: string;
|
|
353
|
+
agent: string;
|
|
354
|
+
workKind?: string;
|
|
355
|
+
startedAt?: number;
|
|
356
|
+
prompt?: string;
|
|
357
|
+
}): void {
|
|
358
|
+
if (this.children.has(info.childId)) return;
|
|
359
|
+
let run = this.runs.get(info.runId);
|
|
360
|
+
if (!run) {
|
|
361
|
+
run = {
|
|
362
|
+
runId: info.runId,
|
|
363
|
+
kind: "tasks",
|
|
364
|
+
status: "interrupted",
|
|
365
|
+
createdAt: info.startedAt ?? this.now(),
|
|
366
|
+
children: [],
|
|
367
|
+
};
|
|
368
|
+
this.runs.set(info.runId, run);
|
|
369
|
+
}
|
|
370
|
+
const child: InternalChild = {
|
|
371
|
+
childId: info.childId,
|
|
372
|
+
runId: info.runId,
|
|
373
|
+
name: info.name,
|
|
374
|
+
agent: info.agent,
|
|
375
|
+
workKind: info.workKind ?? "other",
|
|
376
|
+
status: "interrupted",
|
|
377
|
+
startedAt: info.startedAt ?? this.now(),
|
|
378
|
+
endedAt: this.now(),
|
|
379
|
+
turn: 1,
|
|
380
|
+
queueMs: 0,
|
|
381
|
+
prompt: info.prompt,
|
|
382
|
+
};
|
|
383
|
+
run.children.push(child);
|
|
384
|
+
this.children.set(child.childId, child);
|
|
385
|
+
this.emit(run);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Bind a live hosted session to an adopted child without sending its first prompt. */
|
|
389
|
+
async attachChild(req: ChildRunRequest): Promise<ChildHandle> {
|
|
390
|
+
const child = this.children.get(req.childId);
|
|
391
|
+
if (!child) throw new Error(`unknown child ${req.childId} (adoptExisting first)`);
|
|
392
|
+
const runner = this.runner;
|
|
393
|
+
if (!runner?.attach) {
|
|
394
|
+
throw new Error(`subagent ${req.childId} has no live session to resume`);
|
|
395
|
+
}
|
|
396
|
+
child.prompt = req.taskPrompt ?? req.prompt;
|
|
397
|
+
child.preamble = req.agent.systemPrompt || undefined;
|
|
398
|
+
child.model = req.model ?? req.agent.model;
|
|
399
|
+
let handle: ChildHandle;
|
|
400
|
+
try {
|
|
401
|
+
handle = await runner.attach(req, (startingHandle) => {
|
|
402
|
+
child.handle = startingHandle;
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
const failed = child.handle as DisposableChildHandle | undefined;
|
|
406
|
+
child.handle = undefined;
|
|
407
|
+
await failed?.dispose?.();
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
if (!this.children.has(child.childId)) return handle;
|
|
411
|
+
child.handle = handle;
|
|
412
|
+
handle.result.then((result) => this.settleChild(child.childId, result));
|
|
413
|
+
const resolved = handle.resolvedModel();
|
|
414
|
+
if (resolved) {
|
|
415
|
+
const thinking = handle.effectiveThinkingLevel?.();
|
|
416
|
+
child.model = `${resolved}${thinking ? `:${thinking}` : ""}`;
|
|
417
|
+
}
|
|
418
|
+
const run = this.runs.get(child.runId);
|
|
419
|
+
if (run) this.emit(run);
|
|
420
|
+
return handle;
|
|
421
|
+
}
|
|
422
|
+
|
|
345
423
|
/** Allocate a pending child inside a run. Returns the child id. */
|
|
346
424
|
addChild(runId: string, info: { name: string; agent: string; workKind?: string }): string {
|
|
347
425
|
const run = this.runs.get(runId);
|