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 +5 -3
- package/extensions/index.ts +71 -11
- package/package.json +1 -1
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 (
|
|
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 **
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 (
|
|
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, {
|
|
806
|
-
|
|
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