pi-subagent-in-memory 0.2.3 → 0.2.5

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 CHANGED
@@ -24,20 +24,22 @@ Spawns an in-process subagent session using pi's `createAgentSession` SDK. The s
24
24
  | `model` | string | | Model ID. Supports `provider/model` format (e.g. `openai/gpt-4o-mini`) |
25
25
  | `thinkingLevel` | string | | Reasoning effort: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. Defaults to `off` |
26
26
  | `cwd` | string | | Working directory for the subagent |
27
- | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded. Defaults to the configured default timeout (300s, see `/saim-timeout`) |
27
+ | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded. Defaults to the configured default timeout (900s, see `/saim-timeout`) |
28
28
  | `columnWidthPercent` | number | | Card width as % of terminal (33–100). Controls card grid layout |
29
29
 
30
30
  If `provider` and `model` are omitted, the subagent inherits the main agent's model.
31
31
 
32
32
  ### ⏱️ Timeouts That Don't Destroy Child Work
33
33
 
34
- Every subagent has a timeout (default **300s**, configurable via `--saim-timeout` / `/saim-timeout`, `0` = unlimited). When a parent agent is aborted or times out while a child subagent is still running, the child is **not killed**. Instead it detaches:
34
+ Every subagent has a timeout (default **900s**, configurable via `--saim-timeout` / `/saim-timeout`, `0` = unlimited). When a parent agent is aborted or times out while a child subagent is still running, the child is **not killed**. Instead it detaches:
35
35
 
36
36
  - The parent's tool call returns immediately with a note that the child continues in the background
37
37
  - The child keeps running, still bounded by its **own** timeout
38
38
  - When it finishes, its output is written to `result.md` (or `error.md` on failure) as usual, so the work can be harvested later
39
39
 
40
- On any failure (timeout, error), whatever text the subagent had already produced is salvaged to `partial-result.md` alongside `error.md` — nothing is silently discarded.
40
+ On any failure (timeout, error), whatever text the subagent had already produced is salvaged to `partial-result.md` alongside `error.md` — nothing is silently discarded. `error.md` also includes a tail of the subagent's recent activity (tool calls, output) and points at `events.jsonl`, so a coordinator can harvest partial findings from a timed-out worker instead of redoing its work.
41
+
42
+ Subagents that can spawn nested subagents see their **own timeout budget** in the nested `subagent_create` tool description (schema only — still no context injection), so they can size child timeouts to leave room for collecting and integrating results before their own deadline.
41
43
 
42
44
  ### 🛑 Nesting Depth Limit
43
45
 
@@ -19,7 +19,7 @@
19
19
  * /saim-clear-tui-overlay — clear all cards & close any overlay
20
20
  * - Runtime limits (each also available as a CLI flag):
21
21
  * /saim-max-depth [n] — max subagent nesting depth (default 2)
22
- * /saim-timeout [seconds] — default subagent timeout (default 300s,
22
+ * /saim-timeout [seconds] — default subagent timeout (default 900s,
23
23
  * 0 = unlimited)
24
24
  * - Parent aborts/timeouts do NOT kill running children: the child detaches,
25
25
  * keeps running under its own timeout, and still writes result.md /
@@ -136,6 +136,14 @@ function appendMessageChunk(card: SubagentCard, chunk: string) {
136
136
  trimCardMessages(card);
137
137
  }
138
138
 
139
+ /** Tail of the card's activity stream, used to make result.md/error.md
140
+ * self-describing when the subagent produced little or no final text. */
141
+ function activityTail(card: SubagentCard, maxChars = 2_000): string {
142
+ const text = card.messages.trim();
143
+ if (!text) return "(no recorded activity)";
144
+ return text.length > maxChars ? `…${text.slice(-maxChars)}` : text;
145
+ }
146
+
139
147
  // ── Shared state — single instance across all nesting levels ────
140
148
  const subagents: SubagentCard[] = [];
141
149
  let currentCtx: { ui: any } | null = null;
@@ -153,7 +161,7 @@ let activeDetailDone: ((result: void) => void) | null = null;
153
161
  // ── Runtime limits (commands & flags) ───────────────────────────
154
162
  const DEFAULT_MAX_DEPTH = 2;
155
163
  const MAX_DEPTH_HARD_LIMIT = 10;
156
- const DEFAULT_TIMEOUT_SECS = 300;
164
+ const DEFAULT_TIMEOUT_SECS = 900;
157
165
  // Max nesting depth: 1 = only the main agent may spawn subagents,
158
166
  // 2 = subagents may spawn one further level, etc. Prevents runaway forks.
159
167
  let maxSubagentDepth = DEFAULT_MAX_DEPTH;
@@ -520,7 +528,7 @@ const SubagentParams = Type.Object({
520
528
  description:
521
529
  "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted " +
522
530
  "(its own in-flight nested subagents keep running and still write their results). " +
523
- "Defaults to the configured default timeout (300s unless changed via --saim-timeout or /saim-timeout).",
531
+ "Defaults to the configured default timeout (900s unless changed via --saim-timeout or /saim-timeout).",
524
532
  minimum: 1,
525
533
  })
526
534
  ),
@@ -602,6 +610,11 @@ async function executeSubagent(
602
610
  throw new Error(`Could not find model ${providerName}/${modelId}. ${details}`.trim());
603
611
  }
604
612
 
613
+ // Timeout handling — explicit params.timeout wins, otherwise the configured
614
+ // default applies. 0 means unlimited. Resolved before session creation so the
615
+ // nested subagent_create tool can advertise this subagent's own budget.
616
+ const timeoutSecs = params.timeout ?? defaultTimeoutSecs;
617
+
605
618
  // Subagents at the max depth don't get the subagent_create tool at all, so
606
619
  // the LLM can't even attempt a deeper fork.
607
620
  const canNest = depth < maxSubagentDepth;
@@ -617,7 +630,7 @@ async function executeSubagent(
617
630
  tools: canNest
618
631
  ? ["read", "bash", "edit", "write", "grep", "find", "ls", "subagent_create"]
619
632
  : ["read", "bash", "edit", "write", "grep", "find", "ls"],
620
- customTools: canNest ? [createSubagentAgentTool(providerName, modelId, cwd, depth + 1)] : [],
633
+ customTools: canNest ? [createSubagentAgentTool(providerName, modelId, cwd, depth + 1, timeoutSecs)] : [],
621
634
  });
622
635
 
623
636
  // Set up JSONL event log
@@ -658,9 +671,6 @@ async function executeSubagent(
658
671
  details: { sessionId: session.sessionId, status: "created" },
659
672
  });
660
673
 
661
- // Timeout handling — explicit params.timeout wins, otherwise the configured
662
- // default applies. 0 means unlimited.
663
- const timeoutSecs = params.timeout ?? defaultTimeoutSecs;
664
674
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
665
675
  const timeoutController = new AbortController();
666
676
  if (timeoutSecs > 0) {
@@ -677,6 +687,11 @@ async function executeSubagent(
677
687
  let toolcallDeltaBuffer = "";
678
688
  let lastPartialUpdateAt = 0;
679
689
  let lastPartialUpdateText = "";
690
+ // True between the model finishing a tool-call block and that tool
691
+ // actually starting execution. If the session ends in this state, the
692
+ // provider/agent loop dropped a tool call on the floor — worth surfacing
693
+ // instead of reporting a bland "no text output".
694
+ let pendingToolCall = false;
680
695
 
681
696
  const buildPartialText = (extraLine?: string) => {
682
697
  let text = finalText;
@@ -753,6 +768,7 @@ async function executeSubagent(
753
768
  } else if (ame.type === "toolcall_delta") {
754
769
  if ("delta" in ame) toolcallDeltaBuffer += (ame as any).delta ?? "";
755
770
  } else if (ame.type === "toolcall_end") {
771
+ pendingToolCall = true;
756
772
  if (toolcallDeltaBuffer) {
757
773
  jsonlAppend(jsonlPath, { ...baseLog, data: { assistantMessageEventType: "toolcall", content: toolcallDeltaBuffer } });
758
774
  toolcallDeltaBuffer = "";
@@ -770,6 +786,7 @@ async function executeSubagent(
770
786
  }
771
787
 
772
788
  case "tool_execution_start":
789
+ pendingToolCall = false;
773
790
  appendMessage(card, `[🔧 ${event.toolName} ⏳]`);
774
791
  jsonlAppend(jsonlPath, { ...baseLog, toolName: event.toolName, args: event.args });
775
792
  lastEventId = eventId;
@@ -802,8 +819,25 @@ async function executeSubagent(
802
819
  card.endedAt = Date.now();
803
820
  appendMessage(card, "[agent completed]");
804
821
  requestSubagentRender();
805
- jsonlAppend(jsonlPath, { ...baseLog, finalTextLength: finalText.length });
806
- resolve(finalText || "Subagent completed with no text output.");
822
+ jsonlAppend(jsonlPath, {
823
+ ...baseLog,
824
+ finalTextLength: finalText.length,
825
+ ...(pendingToolCall && !finalText.trim() ? { endedWithUnexecutedToolCall: true } : {}),
826
+ });
827
+ if (finalText.trim()) {
828
+ resolve(finalText);
829
+ } else {
830
+ const diagnosis = pendingToolCall
831
+ ? "The session ended right after the model emitted a tool call that was never executed " +
832
+ "(likely a provider or agent-loop fault) — treat this result as incomplete."
833
+ : "The session ended without producing any text output.";
834
+ resolve(
835
+ `Subagent completed with no text output. ${diagnosis}\n\n` +
836
+ `## Recent activity\n\n\`\`\`\n${activityTail(card)}\n\`\`\`\n\n` +
837
+ `The full event stream (every tool call and result) is in \`${jsonlPath}\` — ` +
838
+ `partial findings can be harvested from there.`,
839
+ );
840
+ }
807
841
  break;
808
842
 
809
843
  case "turn_start":
@@ -823,6 +857,10 @@ async function executeSubagent(
823
857
  });
824
858
 
825
859
  timeoutController.signal.addEventListener("abort", () => {
860
+ // The timer can fire in the narrow window between agent_end resolving
861
+ // the promise and finalizeSuccess clearing the timer — don't clobber a
862
+ // finished card's status in that case.
863
+ if (card.endedAt !== undefined) return;
826
864
  // Abort only this subagent's own session. Its in-flight nested
827
865
  // subagents receive the abort as a PARENT abort and detach instead of
828
866
  // dying (see ParentAbortedError handling below), so their work is
@@ -866,6 +904,13 @@ async function executeSubagent(
866
904
  writeFileSync(partialPath, finalText, "utf-8");
867
905
  body += `\nPartial output produced before the failure was saved to \`partial-result.md\`.\n`;
868
906
  }
907
+ // A subagent that spent its whole budget on tool calls (browsing, greps…)
908
+ // has an empty finalText, but its work is not lost: surface the activity
909
+ // tail and point at events.jsonl so a coordinator can harvest findings.
910
+ body += `\n## Recent activity\n\n\`\`\`\n${activityTail(card)}\n\`\`\`\n`;
911
+ body +=
912
+ `\nThe full event stream (every tool call and its result) is in \`events.jsonl\` ` +
913
+ `in this folder — partial findings can be harvested from there even though the run failed.\n`;
869
914
  writeFileSync(errorPath, body, "utf-8");
870
915
  return { errorPath, partialPath };
871
916
  };
@@ -918,7 +963,9 @@ async function executeSubagent(
918
963
  type: "text",
919
964
  text:
920
965
  `Execution failed. Detail is in \`${errorPath}\`` +
921
- (partialPath ? ` (partial output in \`${partialPath}\`)` : ""),
966
+ (partialPath ? ` (partial output in \`${partialPath}\`)` : "") +
967
+ `. The full activity log is in \`${join(outDir, "events.jsonl")}\` — ` +
968
+ `if the subagent did useful tool work before failing, harvest it from there instead of redoing it.`,
922
969
  },
923
970
  ],
924
971
  details: { sessionId: session.sessionId, status: "error", outputDir: outDir },
@@ -932,14 +979,27 @@ function createSubagentAgentTool(
932
979
  parentModel: string,
933
980
  parentCwd: string,
934
981
  childDepth: number,
982
+ parentBudgetSecs: number,
935
983
  ): ToolDefinition<typeof SubagentParams> {
984
+ // The spawning agent has no other way to know its own timeout, and fan-outs
985
+ // sized without that knowledge burn the whole budget waiting on children
986
+ // (child timeout + integration must fit inside the parent's budget). The
987
+ // tool schema is the one place this extension is allowed to say so.
988
+ const budgetNote =
989
+ parentBudgetSecs > 0
990
+ ? ` IMPORTANT: your own execution budget is ${parentBudgetSecs}s from your session start. ` +
991
+ `Give nested subagents timeouts well below your remaining time so you can still collect and ` +
992
+ `integrate their results before your own deadline; also instruct them to persist intermediate ` +
993
+ `findings to files so a timeout does not lose their work.`
994
+ : "";
936
995
  return {
937
996
  name: "subagent_create",
938
997
  label: "Subagent",
939
998
  description:
940
999
  "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
941
1000
  "Progress is streamed back as execution updates. Returns the final result when the subagent finishes. " +
942
- "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead.",
1001
+ "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead." +
1002
+ budgetNote,
943
1003
  parameters: SubagentParams,
944
1004
  async execute(
945
1005
  toolCallId: string,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagent-in-memory",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "In-process subagent tool for pi with live TUI card widgets, JSONL session logging, and zero system-prompt overhead.",
5
5
  "repository": {
6
6
  "type": "git",