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.
@@ -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
- try {
484
- await this.session?.abort();
485
- } catch {
486
- // abort is best-effort; the result is already settled
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: failure.errorMessage?.trim() || `Model stopped with ${failure.stopReason}`,
697
- endReason: "model-error",
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;
@@ -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
- const record = params.run_id
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)`);
@@ -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;