@clanker-code/pi-subagents 0.10.6 → 0.10.8
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/CHANGELOG.md +12 -1
- package/README.md +6 -5
- package/dist/agent-manager.d.ts +4 -0
- package/dist/agent-manager.js +4 -0
- package/dist/bounded-output.d.ts +16 -0
- package/dist/bounded-output.js +26 -0
- package/dist/index.js +44 -17
- package/dist/notifications.js +7 -5
- package/dist/peek.js +24 -11
- package/dist/wait.d.ts +18 -4
- package/dist/wait.js +48 -3
- package/package.json +1 -1
- package/src/agent-manager.ts +8 -0
- package/src/bounded-output.ts +35 -0
- package/src/index.ts +43 -17
- package/src/notifications.ts +7 -5
- package/src/peek.ts +25 -12
- package/src/wait.ts +55 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.8] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **`get_subagent_result` wait:true now detects queued user messages** — when the parent session has user messages waiting (e.g. the user typed while waiting), the tool returns early with a `pending_message` outcome instead of blocking for the full wait timeout. The queued message is delivered to the parent LLM immediately. Uses `ctx.hasPendingMessages()` polling during the wait. The subagent continues running undisturbed.
|
|
14
|
+
|
|
15
|
+
## [0.10.7] - 2026-06-23
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **`get_subagent_result` output is now bounded** — normal result previews, `verbose: true` conversation previews, and `peek` responses are capped so a large subagent transcript cannot flood the parent context. Truncated responses include the omitted size, continuation guidance, and the agent output file path for full-log inspection. Output files are now attached to background records before the agent can complete, avoiding a fast-completion race where retrieval guidance could miss the transcript path.
|
|
19
|
+
- **Agent-runner e2e no longer waits on an unnecessary prompt turn** — the real-runtime tool-gating test now stops after capturing active tools at session construction, avoiding intermittent 30s timeouts under full-suite load.
|
|
20
|
+
|
|
10
21
|
## [0.10.6] - 2026-06-22
|
|
11
22
|
|
|
12
23
|
### Changed
|
|
@@ -28,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
28
39
|
- **Fork metadata applied** — package author, repository, homepage, bugs, and media URLs now point to the `clankercode/pi-subagents` fork.
|
|
29
40
|
|
|
30
41
|
### Fixed
|
|
31
|
-
- **Subagent completion notifications now include final output and explicit retrieval guidance** — both the machine-readable XML payload and the visible custom renderer include a `get_subagent_result <id>` instruction plus the transcript file path
|
|
42
|
+
- **Subagent completion notifications now include final output previews and explicit retrieval guidance** — both the machine-readable XML payload and the visible custom renderer include a `get_subagent_result <id>` bounded-preview instruction plus the transcript file path for full-log inspection when available.
|
|
32
43
|
|
|
33
44
|
## [0.10.3] - 2026-06-12
|
|
34
45
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
|
|
|
19
19
|
### Fork-specific differences
|
|
20
20
|
|
|
21
21
|
- **Packaging under `@clanker-code`** — published from this fork, not the upstream namespace.
|
|
22
|
-
- **Completion notifications include
|
|
22
|
+
- **Completion notifications include bounded-output guidance** — every notification carries a final output preview plus an explicit `get_subagent_result <id>` bounded-preview instruction and transcript path for full-log inspection when available.
|
|
23
23
|
- Additional fork-specific changes are listed in the [CHANGELOG](./CHANGELOG.md).
|
|
24
24
|
|
|
25
25
|
Upstream changes are reviewed for cherry-picking when practical; otherwise they are reimplemented to fit this fork.
|
|
@@ -41,7 +41,7 @@ Upstream changes are reviewed for cherry-picking when practical; otherwise they
|
|
|
41
41
|
- **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
|
|
42
42
|
- **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
|
|
43
43
|
- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
|
|
44
|
-
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show
|
|
44
|
+
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show a bounded preview and transcript path. Group completions render each agent individually
|
|
45
45
|
- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
|
|
46
46
|
- **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on load
|
|
47
47
|
- **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same steering-style `subagent-notification` path as manual background completions; manage via `/agents → Scheduled jobs`
|
|
@@ -279,17 +279,18 @@ Launch a sub-agent.
|
|
|
279
279
|
| `isolation` | `"worktree"` | no | Run in an isolated git worktree |
|
|
280
280
|
| `inherit_context` | boolean | no | Fork parent conversation into agent |
|
|
281
281
|
|
|
282
|
-
All `Agent` calls run in the background. Completion notifications are delivered automatically; use `get_subagent_result` only when you explicitly need to check status or
|
|
282
|
+
All `Agent` calls run in the background. Completion notifications are delivered automatically; use `get_subagent_result` only when you explicitly need to check status or inspect a bounded result preview. Full transcripts are written to the agent output file shown in the `Agent` response and `get_subagent_result` output.
|
|
283
283
|
|
|
284
284
|
### `get_subagent_result`
|
|
285
285
|
|
|
286
|
-
Check status and retrieve
|
|
286
|
+
Check status and retrieve bounded output from a background agent. The tool is safe-by-default for LLM context: normal results, verbose conversation output, and `peek` responses are capped. If output is truncated, the response includes the output file path and continuation guidance.
|
|
287
287
|
|
|
288
288
|
| Parameter | Type | Required | Description |
|
|
289
289
|
|-----------|------|----------|-------------|
|
|
290
290
|
| `agent_id` | string | yes | Agent ID to check |
|
|
291
291
|
| `wait` | boolean | no | Wait for completion |
|
|
292
|
-
| `verbose` | boolean | no | Include
|
|
292
|
+
| `verbose` | boolean | no | Include a bounded conversation preview |
|
|
293
|
+
| `peek` | object | no | Return a bounded tail/filter view with line numbers. Supports `lines`, `regex`, and `after` for incremental reads. |
|
|
293
294
|
|
|
294
295
|
### `steer_subagent`
|
|
295
296
|
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -53,6 +53,10 @@ interface SpawnOptions {
|
|
|
53
53
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
54
54
|
/** Called on streaming text deltas from the assistant response. */
|
|
55
55
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
56
|
+
/** Build a per-agent output file path before the agent can complete. */
|
|
57
|
+
outputFileForAgent?: (agentId: string) => string;
|
|
58
|
+
/** Called after the output file path is attached to the record. */
|
|
59
|
+
onOutputFileCreated?: (path: string, agentId: string) => void;
|
|
56
60
|
/** Called when the agent session is created (for accessing session stats). */
|
|
57
61
|
onSessionCreated?: (session: AgentSession) => void;
|
|
58
62
|
/** Called at the end of each agentic turn with the cumulative count. */
|
package/dist/agent-manager.js
CHANGED
|
@@ -98,6 +98,10 @@ export class AgentManager {
|
|
|
98
98
|
depth,
|
|
99
99
|
parentAgentId: options.parentAgentId,
|
|
100
100
|
};
|
|
101
|
+
if (options.outputFileForAgent) {
|
|
102
|
+
record.outputFile = options.outputFileForAgent(id);
|
|
103
|
+
options.onOutputFileCreated?.(record.outputFile, id);
|
|
104
|
+
}
|
|
101
105
|
this.agents.set(id, record);
|
|
102
106
|
const args = { pi, ctx, type, prompt, options };
|
|
103
107
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared caps for tool responses that may otherwise flood the parent context.
|
|
3
|
+
* Full subagent logs remain available via the per-agent output file.
|
|
4
|
+
*/
|
|
5
|
+
export declare const MAX_RESULT_CHARS = 20000;
|
|
6
|
+
export declare const MAX_VERBOSE_CHARS = 20000;
|
|
7
|
+
export declare const MAX_PEEK_CHARS = 20000;
|
|
8
|
+
export declare const MAX_PEEK_LINES = 200;
|
|
9
|
+
export interface LimitedText {
|
|
10
|
+
text: string;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
omittedChars: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function limitText(text: string, maxChars: number): LimitedText;
|
|
15
|
+
export declare function clampPeekLines(lines: number | undefined): number;
|
|
16
|
+
export declare function formatOutputFileHint(outputFile?: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared caps for tool responses that may otherwise flood the parent context.
|
|
3
|
+
* Full subagent logs remain available via the per-agent output file.
|
|
4
|
+
*/
|
|
5
|
+
export const MAX_RESULT_CHARS = 20_000;
|
|
6
|
+
export const MAX_VERBOSE_CHARS = 20_000;
|
|
7
|
+
export const MAX_PEEK_CHARS = 20_000;
|
|
8
|
+
export const MAX_PEEK_LINES = 200;
|
|
9
|
+
export function limitText(text, maxChars) {
|
|
10
|
+
if (text.length <= maxChars) {
|
|
11
|
+
return { text, truncated: false, omittedChars: 0 };
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
text: text.slice(0, maxChars),
|
|
15
|
+
truncated: true,
|
|
16
|
+
omittedChars: text.length - maxChars,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function clampPeekLines(lines) {
|
|
20
|
+
if (typeof lines !== "number" || !Number.isFinite(lines) || lines < 1)
|
|
21
|
+
return 20;
|
|
22
|
+
return Math.min(Math.floor(lines), MAX_PEEK_LINES);
|
|
23
|
+
}
|
|
24
|
+
export function formatOutputFileHint(outputFile) {
|
|
25
|
+
return outputFile ? ` Full output/log: ${outputFile}` : "";
|
|
26
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { AgentManager } from "./agent-manager.js";
|
|
|
22
22
|
import { getAgentConversation, getCurrentExtensionAgentId, getCurrentExtensionDepth, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
23
23
|
import { buildAgentToolDescription, getModelLabelFromConfig } from "./agent-tool-description.js";
|
|
24
24
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
|
|
25
|
+
import { formatOutputFileHint, limitText, MAX_RESULT_CHARS, MAX_VERBOSE_CHARS } from "./bounded-output.js";
|
|
25
26
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
26
27
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
27
28
|
import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
@@ -41,7 +42,7 @@ import { AgentWidget, buildInvocationTags, describeActivity, formatContextWindow
|
|
|
41
42
|
import { menuSelect } from "./ui/menu-select.js";
|
|
42
43
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
43
44
|
import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
|
|
44
|
-
import { formatWaitTimeout, raceWait, waitTimeoutMessage } from "./wait.js";
|
|
45
|
+
import { formatWaitTimeout, pollPendingMessages, raceWait, waitTimeoutMessage } from "./wait.js";
|
|
45
46
|
// ---- Shared helpers ----
|
|
46
47
|
/** Tool execute return value for a text response. */
|
|
47
48
|
function textResult(msg, details) {
|
|
@@ -236,7 +237,7 @@ export default function (pi) {
|
|
|
236
237
|
}
|
|
237
238
|
pi.sendMessage({
|
|
238
239
|
customType: "subagent-notification",
|
|
239
|
-
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
240
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for a bounded preview; inspect the transcript file path for full output when needed.`,
|
|
240
241
|
display: true,
|
|
241
242
|
details,
|
|
242
243
|
}, { deliverAs: "steer", triggerTurn: true });
|
|
@@ -823,7 +824,7 @@ export default function (pi) {
|
|
|
823
824
|
widget.update();
|
|
824
825
|
const resumeDetails = { displayName: getDisplayName(record.type), description: record.description, subagentType: record.type, modelName: record.invocation?.modelName };
|
|
825
826
|
return textResult(`Agent resumed in background.\nAgent ID: ${record.id}\nType: ${resumeDetails.displayName}\nDescription: ${record.description}\n\n` +
|
|
826
|
-
`You will be notified when this agent completes.\nUse get_subagent_result to
|
|
827
|
+
`You will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, buildDetails(resumeDetails, record, state, { status: "background" }));
|
|
827
828
|
}
|
|
828
829
|
// Background execution
|
|
829
830
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
@@ -852,6 +853,8 @@ export default function (pi) {
|
|
|
852
853
|
invocation: agentInvocation,
|
|
853
854
|
depth: nextSubagentDepth,
|
|
854
855
|
parentAgentId: extensionAgentId,
|
|
856
|
+
outputFileForAgent: (agentId) => createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId()),
|
|
857
|
+
onOutputFileCreated: (outputFile, agentId) => writeInitialEntry(outputFile, agentId, P.prompt, ctx.cwd),
|
|
855
858
|
...bgCallbacks,
|
|
856
859
|
});
|
|
857
860
|
}
|
|
@@ -862,15 +865,13 @@ export default function (pi) {
|
|
|
862
865
|
const h = stashInvocation(P, retryHandle, ["isolation"]);
|
|
863
866
|
return retryableResult(h, err instanceof Error ? err.message : String(err), "isolation");
|
|
864
867
|
}
|
|
865
|
-
// Set
|
|
866
|
-
//
|
|
868
|
+
// Set join mode synchronously after spawn. The output file path is
|
|
869
|
+
// attached inside AgentManager.spawn(), before the agent can complete.
|
|
867
870
|
const joinMode = resolveJoinMode(defaultJoinMode);
|
|
868
871
|
const record = manager.getRecord(id);
|
|
869
872
|
if (record) {
|
|
870
873
|
record.joinMode = joinMode;
|
|
871
874
|
record.toolCallId = toolCallId;
|
|
872
|
-
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
873
|
-
writeInitialEntry(record.outputFile, id, P.prompt, ctx.cwd);
|
|
874
875
|
}
|
|
875
876
|
if (joinMode === 'async') {
|
|
876
877
|
// No join mode or explicit async — not part of any batch
|
|
@@ -900,7 +901,7 @@ export default function (pi) {
|
|
|
900
901
|
return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\nAgent ID: ${id}\nType: ${displayName}\nDescription: ${P.description}\n` +
|
|
901
902
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
902
903
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
903
|
-
`\nYou will be notified when this agent completes.\nUse get_subagent_result to
|
|
904
|
+
`\nYou will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
|
|
904
905
|
},
|
|
905
906
|
}));
|
|
906
907
|
// ---- get_subagent_result tool ----
|
|
@@ -917,7 +918,7 @@ export default function (pi) {
|
|
|
917
918
|
description: `If true, block until the agent completes before returning. Blocks up to the configured wait timeout (${formatWaitTimeout(getWaitTimeoutSeconds())} by default); if the agent is still running when the timeout is reached, returns its current status — call again with wait: true to keep waiting. Interruptible by the parent turn. Default: false.`,
|
|
918
919
|
})),
|
|
919
920
|
verbose: Type.Optional(Type.Boolean({
|
|
920
|
-
description: "If true, include the agent's
|
|
921
|
+
description: "If true, include a bounded preview of the agent's conversation (messages + tool calls). Default: false.",
|
|
921
922
|
})),
|
|
922
923
|
peek: Type.Optional(Type.Object({
|
|
923
924
|
lines: Type.Optional(Type.Number({ minimum: 1, description: "Number of trailing lines to return. Default: 20." })),
|
|
@@ -927,7 +928,7 @@ export default function (pi) {
|
|
|
927
928
|
description: "Return a lightweight tail/filter view of the agent's result or live output file, with line numbers. Ignored when verbose is true.",
|
|
928
929
|
})),
|
|
929
930
|
}),
|
|
930
|
-
execute: async (_toolCallId, params, signal, _onUpdate,
|
|
931
|
+
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
931
932
|
const record = manager.getRecord(params.agent_id);
|
|
932
933
|
if (!record) {
|
|
933
934
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
@@ -936,7 +937,7 @@ export default function (pi) {
|
|
|
936
937
|
if (params.peek && !params.verbose) {
|
|
937
938
|
const peek = peekAgentOutput(record, params.peek);
|
|
938
939
|
return textResult(peek
|
|
939
|
-
? `${peek.text}\n\n---\nUse verbose: true for
|
|
940
|
+
? `${peek.text}\n\n---\nUse verbose: true for a bounded conversation preview, or omit peek for a bounded result preview.`
|
|
940
941
|
: "No output yet for this agent.");
|
|
941
942
|
}
|
|
942
943
|
// WAIT — race the agent's completion against the configured timeout and
|
|
@@ -945,7 +946,18 @@ export default function (pi) {
|
|
|
945
946
|
let waitOutcome = "completed";
|
|
946
947
|
if (params.wait && record.status === "running" && record.promise) {
|
|
947
948
|
cancelNudge(params.agent_id);
|
|
948
|
-
|
|
949
|
+
// Poll for queued user messages so we can return early and let the
|
|
950
|
+
// parent LLM process them immediately instead of blocking for the
|
|
951
|
+
// full wait timeout.
|
|
952
|
+
const pending = typeof ctx?.hasPendingMessages === "function"
|
|
953
|
+
? pollPendingMessages(() => ctx.hasPendingMessages())
|
|
954
|
+
: undefined;
|
|
955
|
+
try {
|
|
956
|
+
waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds(), pending?.promise);
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
pending?.cancel();
|
|
960
|
+
}
|
|
949
961
|
if (waitOutcome === "completed") {
|
|
950
962
|
record.resultConsumed = true;
|
|
951
963
|
}
|
|
@@ -964,27 +976,42 @@ export default function (pi) {
|
|
|
964
976
|
statsParts.push(`Duration: ${duration}`);
|
|
965
977
|
let output = `Agent: ${record.id}\n` +
|
|
966
978
|
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
967
|
-
`Description: ${record.description}\n
|
|
979
|
+
`Description: ${record.description}\n` +
|
|
980
|
+
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
981
|
+
`\n`;
|
|
968
982
|
if (record.status === "running") {
|
|
969
983
|
// The wait returned while the agent was still running (timeout or abort).
|
|
970
984
|
output += waitTimeoutMessage(waitOutcome, getWaitTimeoutSeconds());
|
|
971
985
|
}
|
|
972
986
|
else if (record.status === "error") {
|
|
973
|
-
|
|
987
|
+
const limited = limitText(record.error ?? "unknown", MAX_RESULT_CHARS);
|
|
988
|
+
output += `Error: ${limited.text}`;
|
|
989
|
+
if (limited.truncated) {
|
|
990
|
+
output += `\n\n---\nError truncated: omitted ${limited.omittedChars} chars. Inspect the output file for the full error when available.${formatOutputFileHint(record.outputFile)}`;
|
|
991
|
+
}
|
|
974
992
|
}
|
|
975
993
|
else {
|
|
976
|
-
|
|
994
|
+
const resultText = record.result?.trim() || "No output.";
|
|
995
|
+
const limited = limitText(resultText, MAX_RESULT_CHARS);
|
|
996
|
+
output += limited.text;
|
|
997
|
+
if (limited.truncated) {
|
|
998
|
+
output += `\n\n---\nResult truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
|
|
999
|
+
}
|
|
977
1000
|
}
|
|
978
1001
|
// Mark result as consumed — suppresses the completion notification
|
|
979
1002
|
if (record.status !== "running" && record.status !== "queued") {
|
|
980
1003
|
record.resultConsumed = true;
|
|
981
1004
|
cancelNudge(params.agent_id);
|
|
982
1005
|
}
|
|
983
|
-
// Verbose: include
|
|
1006
|
+
// Verbose: include a bounded conversation preview
|
|
984
1007
|
if (params.verbose && record.session) {
|
|
985
1008
|
const conversation = getAgentConversation(record.session);
|
|
986
1009
|
if (conversation) {
|
|
987
|
-
|
|
1010
|
+
const limited = limitText(conversation, MAX_VERBOSE_CHARS);
|
|
1011
|
+
output += `\n\n--- Agent Conversation ---\n${limited.text}`;
|
|
1012
|
+
if (limited.truncated) {
|
|
1013
|
+
output += `\n\n---\nAgent conversation truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
|
|
1014
|
+
}
|
|
988
1015
|
}
|
|
989
1016
|
}
|
|
990
1017
|
return textResult(output);
|
package/dist/notifications.js
CHANGED
|
@@ -23,12 +23,14 @@ export function formatTaskNotification(record, resultMaxLen) {
|
|
|
23
23
|
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
24
24
|
const resultPreview = record.result
|
|
25
25
|
? record.result.length > resultMaxLen
|
|
26
|
-
? record.result.slice(0, resultMaxLen) +
|
|
26
|
+
? record.result.slice(0, resultMaxLen) + (record.outputFile
|
|
27
|
+
? "\n...(truncated, use get_subagent_result for a bounded preview or inspect the transcript file)"
|
|
28
|
+
: "\n...(truncated, use get_subagent_result for a bounded preview)")
|
|
27
29
|
: record.result
|
|
28
30
|
: "No output.";
|
|
29
31
|
const fullOutputInstruction = record.outputFile
|
|
30
|
-
? `Read
|
|
31
|
-
: `Read
|
|
32
|
+
? `Read a bounded preview with get_subagent_result for agent ${record.id}, or inspect the full transcript file: ${record.outputFile}`
|
|
33
|
+
: `Read a bounded preview with get_subagent_result for agent ${record.id}.`;
|
|
32
34
|
return [
|
|
33
35
|
`<task-notification>`,
|
|
34
36
|
`<task-id>${record.id}</task-id>`,
|
|
@@ -96,8 +98,8 @@ export function registerSubagentNotificationRenderer(pi) {
|
|
|
96
98
|
line += "\n " + theme.fg("dim", `⎿ ${preview}`);
|
|
97
99
|
}
|
|
98
100
|
const fullOutputHint = d.outputFile
|
|
99
|
-
? `
|
|
100
|
-
: `
|
|
101
|
+
? `bounded preview: get_subagent_result ${d.id}; full transcript: ${d.outputFile}`
|
|
102
|
+
: `bounded preview: get_subagent_result ${d.id}`;
|
|
101
103
|
line += "\n " + theme.fg("muted", fullOutputHint);
|
|
102
104
|
return line;
|
|
103
105
|
}
|
package/dist/peek.js
CHANGED
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
* `after` for incremental updates without missing anything.
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
-
|
|
18
|
-
const DEFAULT_LINES = 20;
|
|
17
|
+
import { clampPeekLines, formatOutputFileHint, limitText, MAX_PEEK_CHARS, MAX_PEEK_LINES } from "./bounded-output.js";
|
|
19
18
|
/**
|
|
20
19
|
* Produce a peek view of an agent's output. Returns null when there is no
|
|
21
20
|
* source content at all (the caller renders a "no output yet" message).
|
|
@@ -26,20 +25,33 @@ export function peekAgentOutput(record, opts = {}) {
|
|
|
26
25
|
return null;
|
|
27
26
|
const regex = opts.regex ? compileRegex(opts.regex) : undefined;
|
|
28
27
|
const after = typeof opts.after === "number" ? opts.after : -1;
|
|
29
|
-
const tail =
|
|
28
|
+
const tail = clampPeekLines(opts.lines);
|
|
30
29
|
// Index each source line with its original position (1-based for display).
|
|
31
30
|
const indexed = lines.map((text, i) => ({ no: i + 1, text }));
|
|
32
31
|
// Filter-then-select.
|
|
33
32
|
const filtered = regex ? indexed.filter((l) => regex.test(l.text)) : indexed;
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const matching = after >= 0 ? filtered.filter((l) => l.no > after) : filtered;
|
|
34
|
+
const selected = after >= 0 ? matching.slice(0, MAX_PEEK_LINES) : matching.slice(-tail);
|
|
35
|
+
const lineLimited = matching.length > selected.length;
|
|
37
36
|
const totalLines = lines.length;
|
|
38
37
|
const isRunning = record.status === "running" || record.status === "queued";
|
|
39
38
|
const source = isRunning && record.outputFile && existsSync(record.outputFile) ? "outputFile" : "result";
|
|
40
|
-
const header = buildHeader(opts, selected.length, totalLines, source);
|
|
39
|
+
const header = buildHeader(opts, selected.length, totalLines, source, tail, lineLimited, matching.length);
|
|
41
40
|
const body = selected.map((l) => `[${l.no}] ${l.text}`).join("\n");
|
|
42
|
-
|
|
41
|
+
const limited = limitText(body, MAX_PEEK_CHARS);
|
|
42
|
+
const lastLine = selected.at(-1)?.no;
|
|
43
|
+
const notices = [];
|
|
44
|
+
if (lineLimited && lastLine !== undefined) {
|
|
45
|
+
notices.push(`Peek limited to ${MAX_PEEK_LINES} lines from ${matching.length} matching lines. Use peek.after: ${lastLine} to continue.`);
|
|
46
|
+
}
|
|
47
|
+
if (limited.truncated) {
|
|
48
|
+
notices.push(`Peek output truncated by ${limited.omittedChars} chars. Use a smaller lines value or regex filter.${formatOutputFileHint(record.outputFile)}`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
text: `${header}\n\n${limited.text}${notices.length ? `\n\n---\n${notices.join("\n")}` : ""}`,
|
|
52
|
+
totalLines,
|
|
53
|
+
source,
|
|
54
|
+
};
|
|
43
55
|
}
|
|
44
56
|
/** Read the most useful text lines from the agent's output. */
|
|
45
57
|
function readSourceLines(record) {
|
|
@@ -105,14 +117,15 @@ function compileRegex(pattern) {
|
|
|
105
117
|
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
|
-
function buildHeader(opts, shown, total, source) {
|
|
120
|
+
function buildHeader(opts, shown, total, source, tail, lineLimited, matching) {
|
|
109
121
|
const parts = [];
|
|
110
122
|
if (typeof opts.after === "number") {
|
|
111
123
|
parts.push(`after line number ${opts.after}`);
|
|
124
|
+
if (lineLimited)
|
|
125
|
+
parts.push(`limited to ${MAX_PEEK_LINES} lines from ${matching} matching lines`);
|
|
112
126
|
}
|
|
113
127
|
else {
|
|
114
|
-
|
|
115
|
-
parts.push(`last ${n} lines`);
|
|
128
|
+
parts.push(`last ${tail} lines`);
|
|
116
129
|
}
|
|
117
130
|
if (opts.regex)
|
|
118
131
|
parts.push(`filtered by regex /${opts.regex}/`);
|
package/dist/wait.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
export type WaitOutcome = "completed" | "timeout" | "aborted";
|
|
1
|
+
export type WaitOutcome = "completed" | "timeout" | "aborted" | "pending_message";
|
|
2
2
|
/** Human-readable "Xm Ys" for a duration in seconds. */
|
|
3
3
|
export declare function formatWaitTimeout(seconds: number): string;
|
|
4
4
|
/**
|
|
5
|
-
* Race an agent completion promise against the configured wait timeout
|
|
6
|
-
* parent abort signal. The subagent is
|
|
5
|
+
* Race an agent completion promise against the configured wait timeout, the
|
|
6
|
+
* parent abort signal, and an optional pending-message check. The subagent is
|
|
7
|
+
* never aborted here.
|
|
8
|
+
*
|
|
9
|
+
* @param pendingCheck - Optional promise that resolves when the parent session
|
|
10
|
+
* has queued user messages waiting to be delivered. When it resolves, the
|
|
11
|
+
* wait ends early so the parent turn can process the incoming message.
|
|
7
12
|
*/
|
|
8
|
-
export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number): Promise<WaitOutcome>;
|
|
13
|
+
export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number, pendingCheck?: Promise<void>): Promise<WaitOutcome>;
|
|
9
14
|
/** Message returned when a wait ends with the agent still running. */
|
|
10
15
|
export declare function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number): string;
|
|
16
|
+
/**
|
|
17
|
+
* Create a promise that resolves when the parent session has queued user
|
|
18
|
+
* messages. Polls at the given interval until `hasPendingMessages()` returns
|
|
19
|
+
* true. The caller should race this against the agent completion / timeout.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pollPendingMessages(hasPendingMessages: () => boolean, intervalMs?: number): {
|
|
22
|
+
promise: Promise<void>;
|
|
23
|
+
cancel: () => void;
|
|
24
|
+
};
|
package/dist/wait.js
CHANGED
|
@@ -5,10 +5,15 @@ export function formatWaitTimeout(seconds) {
|
|
|
5
5
|
return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ""}` : `${s}s`;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
* Race an agent completion promise against the configured wait timeout
|
|
9
|
-
* parent abort signal. The subagent is
|
|
8
|
+
* Race an agent completion promise against the configured wait timeout, the
|
|
9
|
+
* parent abort signal, and an optional pending-message check. The subagent is
|
|
10
|
+
* never aborted here.
|
|
11
|
+
*
|
|
12
|
+
* @param pendingCheck - Optional promise that resolves when the parent session
|
|
13
|
+
* has queued user messages waiting to be delivered. When it resolves, the
|
|
14
|
+
* wait ends early so the parent turn can process the incoming message.
|
|
10
15
|
*/
|
|
11
|
-
export function raceWait(promise, signal, timeoutSeconds) {
|
|
16
|
+
export function raceWait(promise, signal, timeoutSeconds, pendingCheck) {
|
|
12
17
|
return new Promise((resolve) => {
|
|
13
18
|
let settled = false;
|
|
14
19
|
const finish = (outcome) => {
|
|
@@ -23,6 +28,7 @@ export function raceWait(promise, signal, timeoutSeconds) {
|
|
|
23
28
|
const onAbort = () => finish("aborted");
|
|
24
29
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
25
30
|
promise.then(() => finish("completed"));
|
|
31
|
+
pendingCheck?.then(() => finish("pending_message"));
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
34
|
/** Message returned when a wait ends with the agent still running. */
|
|
@@ -33,5 +39,44 @@ export function waitTimeoutMessage(outcome, timeoutSeconds) {
|
|
|
33
39
|
if (outcome === "aborted") {
|
|
34
40
|
return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
|
|
35
41
|
}
|
|
42
|
+
if (outcome === "pending_message") {
|
|
43
|
+
return `Agent is still running. The wait was interrupted by an incoming user message. The subagent was NOT stopped — it continues in the background.\nThe queued message will be delivered after this tool returns.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
|
|
44
|
+
}
|
|
36
45
|
return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Create a promise that resolves when the parent session has queued user
|
|
49
|
+
* messages. Polls at the given interval until `hasPendingMessages()` returns
|
|
50
|
+
* true. The caller should race this against the agent completion / timeout.
|
|
51
|
+
*/
|
|
52
|
+
export function pollPendingMessages(hasPendingMessages, intervalMs = 1000) {
|
|
53
|
+
let settled = false;
|
|
54
|
+
let resolve;
|
|
55
|
+
const promise = new Promise((r) => { resolve = r; });
|
|
56
|
+
// Check immediately in case a message arrived between the tool call
|
|
57
|
+
// start and this poll setup.
|
|
58
|
+
if (hasPendingMessages()) {
|
|
59
|
+
settled = true;
|
|
60
|
+
resolve();
|
|
61
|
+
return { promise, cancel: () => { } };
|
|
62
|
+
}
|
|
63
|
+
const timer = setInterval(() => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
if (hasPendingMessages()) {
|
|
67
|
+
settled = true;
|
|
68
|
+
clearInterval(timer);
|
|
69
|
+
resolve();
|
|
70
|
+
}
|
|
71
|
+
}, intervalMs);
|
|
72
|
+
return {
|
|
73
|
+
promise,
|
|
74
|
+
cancel: () => {
|
|
75
|
+
if (!settled) {
|
|
76
|
+
settled = true;
|
|
77
|
+
clearInterval(timer);
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -90,6 +90,10 @@ interface SpawnOptions {
|
|
|
90
90
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
91
91
|
/** Called on streaming text deltas from the assistant response. */
|
|
92
92
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
93
|
+
/** Build a per-agent output file path before the agent can complete. */
|
|
94
|
+
outputFileForAgent?: (agentId: string) => string;
|
|
95
|
+
/** Called after the output file path is attached to the record. */
|
|
96
|
+
onOutputFileCreated?: (path: string, agentId: string) => void;
|
|
93
97
|
/** Called when the agent session is created (for accessing session stats). */
|
|
94
98
|
onSessionCreated?: (session: AgentSession) => void;
|
|
95
99
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
@@ -185,6 +189,10 @@ export class AgentManager {
|
|
|
185
189
|
depth,
|
|
186
190
|
parentAgentId: options.parentAgentId,
|
|
187
191
|
};
|
|
192
|
+
if (options.outputFileForAgent) {
|
|
193
|
+
record.outputFile = options.outputFileForAgent(id);
|
|
194
|
+
options.onOutputFileCreated?.(record.outputFile, id);
|
|
195
|
+
}
|
|
188
196
|
this.agents.set(id, record);
|
|
189
197
|
|
|
190
198
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared caps for tool responses that may otherwise flood the parent context.
|
|
3
|
+
* Full subagent logs remain available via the per-agent output file.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const MAX_RESULT_CHARS = 20_000;
|
|
7
|
+
export const MAX_VERBOSE_CHARS = 20_000;
|
|
8
|
+
export const MAX_PEEK_CHARS = 20_000;
|
|
9
|
+
export const MAX_PEEK_LINES = 200;
|
|
10
|
+
|
|
11
|
+
export interface LimitedText {
|
|
12
|
+
text: string;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
omittedChars: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function limitText(text: string, maxChars: number): LimitedText {
|
|
18
|
+
if (text.length <= maxChars) {
|
|
19
|
+
return { text, truncated: false, omittedChars: 0 };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
text: text.slice(0, maxChars),
|
|
23
|
+
truncated: true,
|
|
24
|
+
omittedChars: text.length - maxChars,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clampPeekLines(lines: number | undefined): number {
|
|
29
|
+
if (typeof lines !== "number" || !Number.isFinite(lines) || lines < 1) return 20;
|
|
30
|
+
return Math.min(Math.floor(lines), MAX_PEEK_LINES);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatOutputFileHint(outputFile?: string): string {
|
|
34
|
+
return outputFile ? ` Full output/log: ${outputFile}` : "";
|
|
35
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { AgentManager } from "./agent-manager.js";
|
|
|
23
23
|
import { getAgentConversation, getCurrentExtensionAgentId, getCurrentExtensionDepth, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
24
24
|
import { buildAgentToolDescription, getModelLabelFromConfig } from "./agent-tool-description.js";
|
|
25
25
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
|
|
26
|
+
import { formatOutputFileHint, limitText, MAX_RESULT_CHARS, MAX_VERBOSE_CHARS } from "./bounded-output.js";
|
|
26
27
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
27
28
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
28
29
|
import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
@@ -54,7 +55,7 @@ import type { WidgetAgentSnapshot, WidgetDisplayMode } from "./ui/agent-widget-t
|
|
|
54
55
|
import { menuSelect } from "./ui/menu-select.js";
|
|
55
56
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
56
57
|
import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
|
|
57
|
-
import { formatWaitTimeout, raceWait, type WaitOutcome, waitTimeoutMessage } from "./wait.js";
|
|
58
|
+
import { formatWaitTimeout, pollPendingMessages, raceWait, type WaitOutcome, waitTimeoutMessage } from "./wait.js";
|
|
58
59
|
|
|
59
60
|
// ---- Shared helpers ----
|
|
60
61
|
|
|
@@ -280,7 +281,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
281
|
|
|
281
282
|
pi.sendMessage<NotificationDetails>({
|
|
282
283
|
customType: "subagent-notification",
|
|
283
|
-
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
284
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for a bounded preview; inspect the transcript file path for full output when needed.`,
|
|
284
285
|
display: true,
|
|
285
286
|
details,
|
|
286
287
|
}, { deliverAs: "steer", triggerTurn: true });
|
|
@@ -953,7 +954,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
953
954
|
const resumeDetails = { displayName: getDisplayName(record.type), description: record.description, subagentType: record.type, modelName: record.invocation?.modelName };
|
|
954
955
|
return textResult(
|
|
955
956
|
`Agent resumed in background.\nAgent ID: ${record.id}\nType: ${resumeDetails.displayName}\nDescription: ${record.description}\n\n` +
|
|
956
|
-
`You will be notified when this agent completes.\nUse get_subagent_result to
|
|
957
|
+
`You will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
|
|
957
958
|
buildDetails(resumeDetails, record, state, { status: "background" }),
|
|
958
959
|
);
|
|
959
960
|
}
|
|
@@ -987,6 +988,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
987
988
|
invocation: agentInvocation,
|
|
988
989
|
depth: nextSubagentDepth,
|
|
989
990
|
parentAgentId: extensionAgentId,
|
|
991
|
+
outputFileForAgent: (agentId) => createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId()),
|
|
992
|
+
onOutputFileCreated: (outputFile, agentId) => writeInitialEntry(outputFile, agentId, P.prompt!, ctx.cwd),
|
|
990
993
|
...bgCallbacks,
|
|
991
994
|
});
|
|
992
995
|
} catch (err) {
|
|
@@ -997,15 +1000,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
997
1000
|
return retryableResult(h, err instanceof Error ? err.message : String(err), "isolation");
|
|
998
1001
|
}
|
|
999
1002
|
|
|
1000
|
-
// Set
|
|
1001
|
-
//
|
|
1003
|
+
// Set join mode synchronously after spawn. The output file path is
|
|
1004
|
+
// attached inside AgentManager.spawn(), before the agent can complete.
|
|
1002
1005
|
const joinMode = resolveJoinMode(defaultJoinMode);
|
|
1003
1006
|
const record = manager.getRecord(id);
|
|
1004
1007
|
if (record) {
|
|
1005
1008
|
record.joinMode = joinMode;
|
|
1006
1009
|
record.toolCallId = toolCallId;
|
|
1007
|
-
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
1008
|
-
writeInitialEntry(record.outputFile, id, P.prompt!, ctx.cwd);
|
|
1009
1010
|
}
|
|
1010
1011
|
|
|
1011
1012
|
if (joinMode === 'async') {
|
|
@@ -1038,7 +1039,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1038
1039
|
`Agent ${isQueued ? "queued" : "started"} in background.\nAgent ID: ${id}\nType: ${displayName}\nDescription: ${P.description}\n` +
|
|
1039
1040
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1040
1041
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1041
|
-
`\nYou will be notified when this agent completes.\nUse get_subagent_result to
|
|
1042
|
+
`\nYou will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
|
|
1042
1043
|
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
1043
1044
|
);
|
|
1044
1045
|
},
|
|
@@ -1063,7 +1064,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1063
1064
|
),
|
|
1064
1065
|
verbose: Type.Optional(
|
|
1065
1066
|
Type.Boolean({
|
|
1066
|
-
description: "If true, include the agent's
|
|
1067
|
+
description: "If true, include a bounded preview of the agent's conversation (messages + tool calls). Default: false.",
|
|
1067
1068
|
}),
|
|
1068
1069
|
),
|
|
1069
1070
|
peek: Type.Optional(
|
|
@@ -1076,7 +1077,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1076
1077
|
}),
|
|
1077
1078
|
),
|
|
1078
1079
|
}),
|
|
1079
|
-
execute: async (_toolCallId, params, signal, _onUpdate,
|
|
1080
|
+
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
1080
1081
|
const record = manager.getRecord(params.agent_id);
|
|
1081
1082
|
if (!record) {
|
|
1082
1083
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
@@ -1087,7 +1088,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1087
1088
|
const peek = peekAgentOutput(record, params.peek as PeekOptions);
|
|
1088
1089
|
return textResult(
|
|
1089
1090
|
peek
|
|
1090
|
-
? `${peek.text}\n\n---\nUse verbose: true for
|
|
1091
|
+
? `${peek.text}\n\n---\nUse verbose: true for a bounded conversation preview, or omit peek for a bounded result preview.`
|
|
1091
1092
|
: "No output yet for this agent.",
|
|
1092
1093
|
);
|
|
1093
1094
|
}
|
|
@@ -1098,7 +1099,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
1098
1099
|
let waitOutcome: WaitOutcome = "completed";
|
|
1099
1100
|
if (params.wait && record.status === "running" && record.promise) {
|
|
1100
1101
|
cancelNudge(params.agent_id);
|
|
1101
|
-
|
|
1102
|
+
// Poll for queued user messages so we can return early and let the
|
|
1103
|
+
// parent LLM process them immediately instead of blocking for the
|
|
1104
|
+
// full wait timeout.
|
|
1105
|
+
const pending = typeof ctx?.hasPendingMessages === "function"
|
|
1106
|
+
? pollPendingMessages(() => ctx.hasPendingMessages())
|
|
1107
|
+
: undefined;
|
|
1108
|
+
try {
|
|
1109
|
+
waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds(), pending?.promise);
|
|
1110
|
+
} finally {
|
|
1111
|
+
pending?.cancel();
|
|
1112
|
+
}
|
|
1102
1113
|
if (waitOutcome === "completed") {
|
|
1103
1114
|
record.resultConsumed = true;
|
|
1104
1115
|
}
|
|
@@ -1117,15 +1128,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
1117
1128
|
let output =
|
|
1118
1129
|
`Agent: ${record.id}\n` +
|
|
1119
1130
|
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1120
|
-
`Description: ${record.description}\n
|
|
1131
|
+
`Description: ${record.description}\n` +
|
|
1132
|
+
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1133
|
+
`\n`;
|
|
1121
1134
|
|
|
1122
1135
|
if (record.status === "running") {
|
|
1123
1136
|
// The wait returned while the agent was still running (timeout or abort).
|
|
1124
1137
|
output += waitTimeoutMessage(waitOutcome, getWaitTimeoutSeconds());
|
|
1125
1138
|
} else if (record.status === "error") {
|
|
1126
|
-
|
|
1139
|
+
const limited = limitText(record.error ?? "unknown", MAX_RESULT_CHARS);
|
|
1140
|
+
output += `Error: ${limited.text}`;
|
|
1141
|
+
if (limited.truncated) {
|
|
1142
|
+
output += `\n\n---\nError truncated: omitted ${limited.omittedChars} chars. Inspect the output file for the full error when available.${formatOutputFileHint(record.outputFile)}`;
|
|
1143
|
+
}
|
|
1127
1144
|
} else {
|
|
1128
|
-
|
|
1145
|
+
const resultText = record.result?.trim() || "No output.";
|
|
1146
|
+
const limited = limitText(resultText, MAX_RESULT_CHARS);
|
|
1147
|
+
output += limited.text;
|
|
1148
|
+
if (limited.truncated) {
|
|
1149
|
+
output += `\n\n---\nResult truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
|
|
1150
|
+
}
|
|
1129
1151
|
}
|
|
1130
1152
|
|
|
1131
1153
|
// Mark result as consumed — suppresses the completion notification
|
|
@@ -1134,11 +1156,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1134
1156
|
cancelNudge(params.agent_id);
|
|
1135
1157
|
}
|
|
1136
1158
|
|
|
1137
|
-
// Verbose: include
|
|
1159
|
+
// Verbose: include a bounded conversation preview
|
|
1138
1160
|
if (params.verbose && record.session) {
|
|
1139
1161
|
const conversation = getAgentConversation(record.session);
|
|
1140
1162
|
if (conversation) {
|
|
1141
|
-
|
|
1163
|
+
const limited = limitText(conversation, MAX_VERBOSE_CHARS);
|
|
1164
|
+
output += `\n\n--- Agent Conversation ---\n${limited.text}`;
|
|
1165
|
+
if (limited.truncated) {
|
|
1166
|
+
output += `\n\n---\nAgent conversation truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
|
|
1167
|
+
}
|
|
1142
1168
|
}
|
|
1143
1169
|
}
|
|
1144
1170
|
|
package/src/notifications.ts
CHANGED
|
@@ -30,12 +30,14 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
|
|
|
30
30
|
|
|
31
31
|
const resultPreview = record.result
|
|
32
32
|
? record.result.length > resultMaxLen
|
|
33
|
-
? record.result.slice(0, resultMaxLen) +
|
|
33
|
+
? record.result.slice(0, resultMaxLen) + (record.outputFile
|
|
34
|
+
? "\n...(truncated, use get_subagent_result for a bounded preview or inspect the transcript file)"
|
|
35
|
+
: "\n...(truncated, use get_subagent_result for a bounded preview)")
|
|
34
36
|
: record.result
|
|
35
37
|
: "No output.";
|
|
36
38
|
const fullOutputInstruction = record.outputFile
|
|
37
|
-
? `Read
|
|
38
|
-
: `Read
|
|
39
|
+
? `Read a bounded preview with get_subagent_result for agent ${record.id}, or inspect the full transcript file: ${record.outputFile}`
|
|
40
|
+
: `Read a bounded preview with get_subagent_result for agent ${record.id}.`;
|
|
39
41
|
|
|
40
42
|
return [
|
|
41
43
|
`<task-notification>`,
|
|
@@ -106,8 +108,8 @@ export function registerSubagentNotificationRenderer(pi: ExtensionAPI): void {
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const fullOutputHint = d.outputFile
|
|
109
|
-
? `
|
|
110
|
-
: `
|
|
111
|
+
? `bounded preview: get_subagent_result ${d.id}; full transcript: ${d.outputFile}`
|
|
112
|
+
: `bounded preview: get_subagent_result ${d.id}`;
|
|
111
113
|
line += "\n " + theme.fg("muted", fullOutputHint);
|
|
112
114
|
|
|
113
115
|
return line;
|
package/src/peek.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { clampPeekLines, formatOutputFileHint, limitText, MAX_PEEK_CHARS, MAX_PEEK_LINES } from "./bounded-output.js";
|
|
18
19
|
import type { AgentRecord } from "./types.js";
|
|
19
20
|
|
|
20
21
|
export interface PeekOptions {
|
|
@@ -35,9 +36,6 @@ export interface PeekResult {
|
|
|
35
36
|
source: "outputFile" | "result";
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/** Default number of tail lines when neither `after` nor `lines` is given. */
|
|
39
|
-
const DEFAULT_LINES = 20;
|
|
40
|
-
|
|
41
39
|
/**
|
|
42
40
|
* Produce a peek view of an agent's output. Returns null when there is no
|
|
43
41
|
* source content at all (the caller renders a "no output yet" message).
|
|
@@ -48,27 +46,39 @@ export function peekAgentOutput(record: AgentRecord, opts: PeekOptions = {}): Pe
|
|
|
48
46
|
|
|
49
47
|
const regex = opts.regex ? compileRegex(opts.regex) : undefined;
|
|
50
48
|
const after = typeof opts.after === "number" ? opts.after : -1;
|
|
51
|
-
const tail =
|
|
49
|
+
const tail = clampPeekLines(opts.lines);
|
|
52
50
|
|
|
53
51
|
// Index each source line with its original position (1-based for display).
|
|
54
52
|
const indexed = lines.map((text, i) => ({ no: i + 1, text }));
|
|
55
53
|
|
|
56
54
|
// Filter-then-select.
|
|
57
55
|
const filtered = regex ? indexed.filter((l) => regex.test(l.text)) : indexed;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: filtered.slice(-tail);
|
|
56
|
+
const matching = after >= 0 ? filtered.filter((l) => l.no > after) : filtered;
|
|
57
|
+
const selected = after >= 0 ? matching.slice(0, MAX_PEEK_LINES) : matching.slice(-tail);
|
|
58
|
+
const lineLimited = matching.length > selected.length;
|
|
62
59
|
|
|
63
60
|
const totalLines = lines.length;
|
|
64
61
|
const isRunning = record.status === "running" || record.status === "queued";
|
|
65
62
|
const source: PeekResult["source"] =
|
|
66
63
|
isRunning && record.outputFile && existsSync(record.outputFile) ? "outputFile" : "result";
|
|
67
64
|
|
|
68
|
-
const header = buildHeader(opts, selected.length, totalLines, source);
|
|
65
|
+
const header = buildHeader(opts, selected.length, totalLines, source, tail, lineLimited, matching.length);
|
|
69
66
|
const body = selected.map((l) => `[${l.no}] ${l.text}`).join("\n");
|
|
67
|
+
const limited = limitText(body, MAX_PEEK_CHARS);
|
|
68
|
+
const lastLine = selected.at(-1)?.no;
|
|
69
|
+
const notices: string[] = [];
|
|
70
|
+
if (lineLimited && lastLine !== undefined) {
|
|
71
|
+
notices.push(`Peek limited to ${MAX_PEEK_LINES} lines from ${matching.length} matching lines. Use peek.after: ${lastLine} to continue.`);
|
|
72
|
+
}
|
|
73
|
+
if (limited.truncated) {
|
|
74
|
+
notices.push(`Peek output truncated by ${limited.omittedChars} chars. Use a smaller lines value or regex filter.${formatOutputFileHint(record.outputFile)}`);
|
|
75
|
+
}
|
|
70
76
|
|
|
71
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
text: `${header}\n\n${limited.text}${notices.length ? `\n\n---\n${notices.join("\n")}` : ""}`,
|
|
79
|
+
totalLines,
|
|
80
|
+
source,
|
|
81
|
+
};
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/** Read the most useful text lines from the agent's output. */
|
|
@@ -141,13 +151,16 @@ function buildHeader(
|
|
|
141
151
|
shown: number,
|
|
142
152
|
total: number,
|
|
143
153
|
source: PeekResult["source"],
|
|
154
|
+
tail: number,
|
|
155
|
+
lineLimited: boolean,
|
|
156
|
+
matching: number,
|
|
144
157
|
): string {
|
|
145
158
|
const parts: string[] = [];
|
|
146
159
|
if (typeof opts.after === "number") {
|
|
147
160
|
parts.push(`after line number ${opts.after}`);
|
|
161
|
+
if (lineLimited) parts.push(`limited to ${MAX_PEEK_LINES} lines from ${matching} matching lines`);
|
|
148
162
|
} else {
|
|
149
|
-
|
|
150
|
-
parts.push(`last ${n} lines`);
|
|
163
|
+
parts.push(`last ${tail} lines`);
|
|
151
164
|
}
|
|
152
165
|
if (opts.regex) parts.push(`filtered by regex /${opts.regex}/`);
|
|
153
166
|
parts.push(`of ${total} total (${source === "outputFile" ? "live output file" : "result"})`);
|
package/src/wait.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WaitOutcome = "completed" | "timeout" | "aborted";
|
|
1
|
+
export type WaitOutcome = "completed" | "timeout" | "aborted" | "pending_message";
|
|
2
2
|
|
|
3
3
|
/** Human-readable "Xm Ys" for a duration in seconds. */
|
|
4
4
|
export function formatWaitTimeout(seconds: number): string {
|
|
@@ -8,13 +8,19 @@ export function formatWaitTimeout(seconds: number): string {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Race an agent completion promise against the configured wait timeout
|
|
12
|
-
* parent abort signal. The subagent is
|
|
11
|
+
* Race an agent completion promise against the configured wait timeout, the
|
|
12
|
+
* parent abort signal, and an optional pending-message check. The subagent is
|
|
13
|
+
* never aborted here.
|
|
14
|
+
*
|
|
15
|
+
* @param pendingCheck - Optional promise that resolves when the parent session
|
|
16
|
+
* has queued user messages waiting to be delivered. When it resolves, the
|
|
17
|
+
* wait ends early so the parent turn can process the incoming message.
|
|
13
18
|
*/
|
|
14
19
|
export function raceWait(
|
|
15
20
|
promise: Promise<string>,
|
|
16
21
|
signal: AbortSignal | undefined,
|
|
17
22
|
timeoutSeconds: number,
|
|
23
|
+
pendingCheck?: Promise<void>,
|
|
18
24
|
): Promise<WaitOutcome> {
|
|
19
25
|
return new Promise((resolve) => {
|
|
20
26
|
let settled = false;
|
|
@@ -29,6 +35,7 @@ export function raceWait(
|
|
|
29
35
|
const onAbort = () => finish("aborted");
|
|
30
36
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
31
37
|
promise.then(() => finish("completed"));
|
|
38
|
+
pendingCheck?.then(() => finish("pending_message"));
|
|
32
39
|
});
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -40,5 +47,50 @@ export function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number)
|
|
|
40
47
|
if (outcome === "aborted") {
|
|
41
48
|
return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
|
|
42
49
|
}
|
|
50
|
+
if (outcome === "pending_message") {
|
|
51
|
+
return `Agent is still running. The wait was interrupted by an incoming user message. The subagent was NOT stopped — it continues in the background.\nThe queued message will be delivered after this tool returns.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
|
|
52
|
+
}
|
|
43
53
|
return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
|
|
44
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a promise that resolves when the parent session has queued user
|
|
58
|
+
* messages. Polls at the given interval until `hasPendingMessages()` returns
|
|
59
|
+
* true. The caller should race this against the agent completion / timeout.
|
|
60
|
+
*/
|
|
61
|
+
export function pollPendingMessages(
|
|
62
|
+
hasPendingMessages: () => boolean,
|
|
63
|
+
intervalMs = 1000,
|
|
64
|
+
): { promise: Promise<void>; cancel: () => void } {
|
|
65
|
+
let settled = false;
|
|
66
|
+
let resolve!: () => void;
|
|
67
|
+
const promise = new Promise<void>((r) => { resolve = r; });
|
|
68
|
+
|
|
69
|
+
// Check immediately in case a message arrived between the tool call
|
|
70
|
+
// start and this poll setup.
|
|
71
|
+
if (hasPendingMessages()) {
|
|
72
|
+
settled = true;
|
|
73
|
+
resolve();
|
|
74
|
+
return { promise, cancel: () => {} };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const timer = setInterval(() => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
if (hasPendingMessages()) {
|
|
80
|
+
settled = true;
|
|
81
|
+
clearInterval(timer);
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
}, intervalMs);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
promise,
|
|
88
|
+
cancel: () => {
|
|
89
|
+
if (!settled) {
|
|
90
|
+
settled = true;
|
|
91
|
+
clearInterval(timer);
|
|
92
|
+
resolve();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|