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
package/src/subagent/runner.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface InProcessRunnerOptions {
|
|
|
87
87
|
stallRetryDelayMs?: number;
|
|
88
88
|
/** Called on every stall detection (before any retry), with the 1-based attempt. */
|
|
89
89
|
onStall?: (childId: string, attempt: number) => void;
|
|
90
|
+
/** Same tool name and arguments three times in a row. */
|
|
91
|
+
onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
|
|
90
92
|
/** Overrun reminder interval (ms). Default 10 minutes. */
|
|
91
93
|
overrunRepeatMs?: number;
|
|
92
94
|
/** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
|
|
@@ -116,6 +118,20 @@ function errorMessage(err: unknown): string {
|
|
|
116
118
|
return err instanceof Error ? err.message : String(err);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
/** 429 / rate-limit text is recorded separately from other model errors. */
|
|
122
|
+
export function classifyModelFailure(message: string): {
|
|
123
|
+
endReason: "model-error" | "rate-limit";
|
|
124
|
+
retryAfter?: number;
|
|
125
|
+
} {
|
|
126
|
+
const rateLimited = /\b429\b|rate[\s-]?limit|too many requests/i.test(message);
|
|
127
|
+
if (!rateLimited) return { endReason: "model-error" };
|
|
128
|
+
const retry = /retry[\s-]?after[:\s]+(\d+)/i.exec(message);
|
|
129
|
+
return {
|
|
130
|
+
endReason: "rate-limit",
|
|
131
|
+
...(retry ? { retryAfter: Number(retry[1]) } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
119
135
|
export class InProcessRunner implements ChildRunner {
|
|
120
136
|
private readonly opts: InProcessRunnerOptions;
|
|
121
137
|
|
|
@@ -129,6 +145,13 @@ export class InProcessRunner implements ChildRunner {
|
|
|
129
145
|
await handle.launch();
|
|
130
146
|
return handle;
|
|
131
147
|
}
|
|
148
|
+
|
|
149
|
+
async attach(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<DisposableChildHandle> {
|
|
150
|
+
const handle = new InProcessChildHandle(req, this.opts);
|
|
151
|
+
onHandle?.(handle);
|
|
152
|
+
await handle.attachIdle();
|
|
153
|
+
return handle;
|
|
154
|
+
}
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
class InProcessChildHandle implements DisposableChildHandle {
|
|
@@ -138,6 +161,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
138
161
|
private readonly stallRetries: number;
|
|
139
162
|
private readonly stallRetryDelayMs: number;
|
|
140
163
|
private readonly onStall?: (childId: string, attempt: number) => void;
|
|
164
|
+
private readonly onToolLoop?: (childId: string, toolName: string, args: unknown) => void;
|
|
165
|
+
private lastToolKey: string | null = null;
|
|
166
|
+
private lastToolCount = 0;
|
|
141
167
|
private readonly overrunRepeatMs: number;
|
|
142
168
|
private readonly hardTimeoutMs: number;
|
|
143
169
|
private readonly onOverrun?: (tick: OverrunTick) => void;
|
|
@@ -233,6 +259,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
233
259
|
? Math.max(0, Math.floor(rawDelay))
|
|
234
260
|
: DEFAULT_STALL_RETRY_DELAY_MS;
|
|
235
261
|
this.onStall = opts.onStall;
|
|
262
|
+
this.onToolLoop = opts.onToolLoop;
|
|
236
263
|
const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
|
|
237
264
|
this.overrunRepeatMs =
|
|
238
265
|
Number.isFinite(rawRepeat) && rawRepeat > 0
|
|
@@ -331,6 +358,27 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
331
358
|
await this.beginGeneration(this.req.prompt, true);
|
|
332
359
|
}
|
|
333
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Bind an already-hosted session so resume() can send the next turn.
|
|
363
|
+
* The previous parent died; this generation is interrupted, not prompted.
|
|
364
|
+
*/
|
|
365
|
+
async attachIdle(): Promise<void> {
|
|
366
|
+
this.sessionCreation = this.createSession(this.req);
|
|
367
|
+
const created =
|
|
368
|
+
await this.sessionCreation;
|
|
369
|
+
this.sessionCreation = null;
|
|
370
|
+
if (this.disposed) return;
|
|
371
|
+
this.session = created;
|
|
372
|
+
this.resolvedModel_ = created.resolvedModel;
|
|
373
|
+
this.unsubscribe = this.session.subscribe((event) => this.handleSessionEvent(event));
|
|
374
|
+
this.settle(this.generation, {
|
|
375
|
+
status: "interrupted",
|
|
376
|
+
text: this.session.getLastAssistantText() ?? "",
|
|
377
|
+
error: "parent session ended",
|
|
378
|
+
durationMs: 0,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
334
382
|
async steer(message: string): Promise<void> {
|
|
335
383
|
if (this.status_ !== "running" || !this.session) {
|
|
336
384
|
throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
|
|
@@ -480,11 +528,10 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
480
528
|
text: this.status_ === "pending" ? "" : this.partialText(),
|
|
481
529
|
durationMs: this.now() - this.startedAt,
|
|
482
530
|
});
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
531
|
+
// Resume may be accepted as soon as the interrupted result is visible.
|
|
532
|
+
// Drain this abort just like a timeout/stall abort before prompting again.
|
|
533
|
+
this.abortPromise = this.session ? this.session.abort().catch(() => {}) : null;
|
|
534
|
+
await this.abortPromise;
|
|
488
535
|
}
|
|
489
536
|
|
|
490
537
|
dispose(): void | Promise<void> {
|
|
@@ -690,11 +737,14 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
690
737
|
if (!this.isCurrent(gen)) return;
|
|
691
738
|
const failure = session?.getLastAssistantFailure?.();
|
|
692
739
|
if (failure) {
|
|
740
|
+
const error = failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`;
|
|
741
|
+
const classified = classifyModelFailure(error);
|
|
693
742
|
this.settle(gen, {
|
|
694
743
|
status: "failed",
|
|
695
744
|
text: this.partialText(),
|
|
696
|
-
error
|
|
697
|
-
endReason:
|
|
745
|
+
error,
|
|
746
|
+
endReason: classified.endReason,
|
|
747
|
+
...(classified.retryAfter !== undefined ? { retryAfter: classified.retryAfter } : {}),
|
|
698
748
|
durationMs: this.now() - this.startedAt,
|
|
699
749
|
});
|
|
700
750
|
return;
|
|
@@ -871,7 +921,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
871
921
|
this.toolStartedAt = null;
|
|
872
922
|
}
|
|
873
923
|
|
|
874
|
-
private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; usage?: { input: number; output: number } }): void {
|
|
924
|
+
private handleSessionEvent(event: { type: string; role?: string; toolCallId?: string; toolName?: string; args?: unknown; usage?: { input: number; output: number } }): void {
|
|
875
925
|
const now = this.now();
|
|
876
926
|
const active = this.status_ === "running";
|
|
877
927
|
let currentToolEvent = false;
|
|
@@ -915,6 +965,7 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
915
965
|
if (currentToolEvent) {
|
|
916
966
|
this.toolDepth++;
|
|
917
967
|
this.clearStall();
|
|
968
|
+
this.noteToolCall(event.toolName, event.args);
|
|
918
969
|
}
|
|
919
970
|
return;
|
|
920
971
|
}
|
|
@@ -931,6 +982,22 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
931
982
|
if (this.toolDepth === 0 && !this.decisionPaused) this.armStall(this.generation);
|
|
932
983
|
}
|
|
933
984
|
|
|
985
|
+
private noteToolCall(toolName: string | undefined, args: unknown): void {
|
|
986
|
+
const name = toolName ?? "";
|
|
987
|
+
const key = `${name}\0${JSON.stringify(args ?? null)}`;
|
|
988
|
+
if (this.lastToolKey === key) this.lastToolCount += 1;
|
|
989
|
+
else {
|
|
990
|
+
this.lastToolKey = key;
|
|
991
|
+
this.lastToolCount = 1;
|
|
992
|
+
}
|
|
993
|
+
if (this.lastToolCount !== 3 || !name) return;
|
|
994
|
+
try {
|
|
995
|
+
this.onToolLoop?.(this.req.childId, name, args);
|
|
996
|
+
} catch {
|
|
997
|
+
// loop observers must not break the child
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
934
1001
|
private release(terminal = true): void {
|
|
935
1002
|
const release = this.releaseSlot;
|
|
936
1003
|
if (terminal) this.releaseSlot = null;
|
package/src/subagent/tool.ts
CHANGED
|
@@ -142,6 +142,8 @@ export interface SubagentToolDeps {
|
|
|
142
142
|
/** Selectable models for action:"models" (§4.6); absent → action errors. */
|
|
143
143
|
/** Receives the watcher for turns started by agent_message send. */
|
|
144
144
|
registerResumeWatch?: (watch: (childId: string) => void) => void;
|
|
145
|
+
/** Rebuild a child this session no longer holds so resume can use child_id. */
|
|
146
|
+
recoverChild?: (childId: string) => Promise<RunRecord | undefined>;
|
|
145
147
|
listModels?: () => {
|
|
146
148
|
provider: string;
|
|
147
149
|
id: string;
|
|
@@ -228,6 +230,8 @@ function toNotificationInfo(record: RunRecord, now: number) {
|
|
|
228
230
|
status: c.status,
|
|
229
231
|
text: c.result?.text ?? "",
|
|
230
232
|
error: c.result?.error,
|
|
233
|
+
...(c.result?.endReason !== undefined ? { endReason: c.result.endReason } : {}),
|
|
234
|
+
...(c.result?.retryAfter !== undefined ? { retryAfter: c.result.retryAfter } : {}),
|
|
231
235
|
...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
|
|
232
236
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
233
237
|
})),
|
|
@@ -563,11 +567,16 @@ export function createSubagentTool(
|
|
|
563
567
|
};
|
|
564
568
|
}
|
|
565
569
|
|
|
566
|
-
|
|
570
|
+
let record = params.run_id
|
|
567
571
|
? requireRun(registry, params.run_id)
|
|
568
572
|
: params.child_id
|
|
569
573
|
? resolveRunByChild(registry, params.child_id)
|
|
570
574
|
: undefined;
|
|
575
|
+
if (params.child_id && !params.run_id && action === "resume" && deps.recoverChild &&
|
|
576
|
+
(!record || !registry.findChild(record.runId, params.child_id))) {
|
|
577
|
+
record =
|
|
578
|
+
await deps.recoverChild(params.child_id);
|
|
579
|
+
}
|
|
571
580
|
if (!record) {
|
|
572
581
|
if (params.child_id && !params.run_id) {
|
|
573
582
|
throw new Error(`unknown child "${params.child_id}" (no run in this session holds it)`);
|
package/src/subagent/types.ts
CHANGED
|
@@ -46,7 +46,9 @@ export interface ChildResult {
|
|
|
46
46
|
/** Non-fatal caveat, e.g. agent-definition model fell back to parent model. */
|
|
47
47
|
warning?: string;
|
|
48
48
|
/** Set when the model itself stopped with an error (provider failure). */
|
|
49
|
-
endReason?: "model-error";
|
|
49
|
+
endReason?: "model-error" | "rate-limit";
|
|
50
|
+
/** Seconds until a rate-limited provider may be retried, when known. */
|
|
51
|
+
retryAfter?: number;
|
|
50
52
|
/**
|
|
51
53
|
* Total generations run (1 + resumes + stall retries). Omitted when 1.
|
|
52
54
|
* Note: a user resume() also increments this; use `stalls` to count
|
|
@@ -102,6 +104,9 @@ export interface ChildSessionEvent {
|
|
|
102
104
|
role?: string;
|
|
103
105
|
/** Pi's stable tool-call id, used to pair execution start/end events. */
|
|
104
106
|
toolCallId?: string;
|
|
107
|
+
toolName?: string;
|
|
108
|
+
args?: unknown;
|
|
109
|
+
isError?: boolean;
|
|
105
110
|
usage?: ChildTokenUsage;
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -178,6 +183,8 @@ export type CreateSessionFn = (req: ChildRunRequest) => Promise<ChildSessionAdap
|
|
|
178
183
|
|
|
179
184
|
export interface ChildRunner {
|
|
180
185
|
start(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
|
|
186
|
+
/** Reattach a child the daemon still hosts, without sending its first prompt. */
|
|
187
|
+
attach?(req: ChildRunRequest, onHandle?: (handle: ChildHandle) => void): Promise<ChildHandle>;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/**
|
package/src/work-index.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type WorkKind = "shell" | "monitor" | "agent";
|
|
|
11
11
|
export const WORK_RETAIN_MS = 10 * 60 * 1000;
|
|
12
12
|
export const WORK_FINISHED_CAP = 50;
|
|
13
13
|
|
|
14
|
+
/** stderr line when print mode drops live work and still exits 0. */
|
|
15
|
+
export function printModeDropWarning(ids: string[]): string | null {
|
|
16
|
+
if (ids.length === 0) return null;
|
|
17
|
+
return `pi-famulus: print mode is ending; stopping ${ids.length} background item(s) (${ids.join(", ")}). The process still exits 0.\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
export interface WorkItem {
|
|
15
21
|
id: string;
|
|
16
22
|
kind: WorkKind;
|