pi-open-agents 0.1.30 → 0.1.32

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
@@ -167,6 +167,11 @@ active agent's rules *before it runs* — not just used to filter the toolset:
167
167
  > blocks matching *individual calls* (patterns included), not just statically
168
168
  > hiding whole tools. (#20)
169
169
 
170
+ > **⚠️ Behavior change (v0.1.31):** subagent results are now file + summary
171
+ > by default — full output goes to a temp archive, inline carries the child's
172
+ > closing outcome block (#32). If you parsed full inline output
173
+ > programmatically, read the archive path from the result instead.
174
+
170
175
  > **⚠️ Behavior change (v0.1.30):** primary-agent static filtering now covers
171
176
  > **all tool sources**, not just builtins (#18). If an agent declares
172
177
  > `permission` or `tools:`, extension tools (web, LSP, MCP, …) not matched
@@ -208,12 +213,24 @@ If an agent has the `subagent` tool, the plugin automatically appends a
208
213
  subagents and the correct call syntax:
209
214
 
210
215
  ```
211
- subagent({ agent: "<name>", task: "<task>", cwd?, session?, model? })
216
+ subagent({ agent: "<name>", task: "<task>", cwd?, session?, model?, output? })
212
217
  ```
213
218
 
214
219
  You never need to explain delegation mechanics in agent prompts. The plugin
215
220
  handles it, the same way pi handles tool descriptions.
216
221
 
222
+ ### Subagent output: file + summary (not full-inline)
223
+
224
+ Every run archives its verbatim output to a temp file and returns only a
225
+ short outcome inline — open the file only if the summary is not enough to
226
+ decide the next step. The child is instructed (via its system prompt) to end
227
+ with a `## Outcome` block; without it, the executor falls back to today's
228
+ truncated inline. Pass `output: "<path>"` when the deliverable itself is a
229
+ file: the child writes it there and cites it as `` `<path>` — <what it
230
+ contains> '' ›. On failure the error always comes back inline, never behind a
231
+ file reference. Inspired by @EduardoOrtiz89's fork (`output`/`outputMode`),
232
+ redesigned as the default instead of an opt-in (#32).
233
+
217
234
  ### Subagent execution engine
218
235
 
219
236
  When an agent delegates, the plugin spawns a child pi process with the target
@@ -317,9 +334,11 @@ Use `replace-all` for fully isolated agents that should not be influenced by
317
334
  workspace context — e.g., a subagent that must run identically regardless of
318
335
  the project it's invoked from.
319
336
 
320
- For **subagents** (child process), the agent body **is** the system prompt —
321
- pi's default prompt is not included. Skills are injected as an XML block after
322
- the body.
337
+ For **subagents** (child process), the same modes apply: with the default
338
+ `append`, the agent body is appended to pi's default prompt (`--append-system-prompt`);
339
+ `replace`/`replace-all` pass the body as the whole system prompt (`--system-prompt`).
340
+ Skills are injected as an XML block after the body, followed by the result-closing
341
+ contract (`## Outcome` block, file + summary).
323
342
 
324
343
  ---
325
344
 
@@ -397,7 +416,9 @@ six sessions, zero chaos: that's the exact workstation setup documented in
397
416
  - ~~**Agent hot-reload**~~ — ✅ works via pi's built-in `/reload`, shipped in [0.1.27](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.27) (#9)
398
417
  - ~~**Per-call model override** in `subagent()`~~ — ✅ shipped in [0.1.19](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.19) (#10)
399
418
  - ~~**Primary tool filters incl. extension tools**~~ — ✅ shipped in [0.1.30](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.30) (#18)
400
- - **Parallel subagent limit** — #11
419
+ - ~~**Subagent output: file + summary**~~ — ✅ shipped in [0.1.31](https://github.com/andrea-tomassi/pi-open-agents/releases/tag/v0.1.31) (#32)
420
+ - ~~**Parallel subagent limit**~~ — ✅ closed, see #11 for the outcome
421
+ - **Zero open issues** — the tracker is empty; new work starts from new issues.
401
422
 
402
423
  ---
403
424
 
@@ -408,7 +429,7 @@ opening an issue or pull request.
408
429
 
409
430
  ```bash
410
431
  npm install
411
- npm test # 207 tests
432
+ npm test # 216 tests
412
433
  npm run typecheck # tsc --noEmit
413
434
  ```
414
435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-agents",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Unified agent and subagent management for pi coding agent, with OpenCode-compatible agent definitions.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -261,9 +261,9 @@ export { matchPattern, isWildcardPattern } from "./permission/matcher.ts";
261
261
  export { evaluate, toolToPermission, getDisabledTools, filterDisabledTools } from "./permission/evaluator.ts";
262
262
 
263
263
  // Subagent re-exports
264
- export { runSubagent, resolveSubagentSession, resolvePiEntryPoint, isVirtualBunPath, resolveCompiledCommand } from "./subagent/executor.ts";
264
+ export { runSubagent, resolveSubagentSession, resolvePiEntryPoint, isVirtualBunPath, resolveCompiledCommand, extractOutcomeBlock, OUTCOME_MARKER } from "./subagent/executor.ts";
265
265
  export type { AgentProgress, AgentResult, RunSubagentOptions, SubagentSessionInfo, SubagentSessionMode, HostProcessProbe } from "./subagent/executor.ts";
266
266
  export { resolveSkills } from "./subagent/skills.ts";
267
267
  export type { ResolvedSkill } from "./subagent/skills.ts";
268
- export { buildSubagentPrompt } from "./subagent/prompt.ts";
268
+ export { buildSubagentPrompt, buildClosingContract } from "./subagent/prompt.ts";
269
269
  export { formatResultLines, formatUsage } from "./subagent/render.ts";
@@ -201,7 +201,7 @@ export function buildSubagentGuidance(
201
201
  .map((a) => `- ${a.name}: ${a.description ?? "Subagent for delegated tasks."}`)
202
202
  .join("\n");
203
203
 
204
- return `\n\n## Subagent Delegation\n\nYou have ${available.length} subagent(s) available. Delegate isolated tasks using the \`subagent\` tool:\n\nsubagent({ agent: "<name>", task: "<task description>" })\n\nAvailable subagents:\n${list}\n\nIMPORTANT: Subagent names are NOT bash commands. Always use the \`subagent\` tool to delegate — never write scripts that call agent names as CLI commands.`;
204
+ return `\n\n## Subagent Delegation\n\nYou have ${available.length} subagent(s) available. Delegate isolated tasks using the \`subagent\` tool:\n\nsubagent({ agent: "<name>", task: "<task description>" })\n\nAvailable subagents:\n${list}\n\nSubagent output: every run saves its full output to a temp file and returns only a short outcome summary — open the file only if the summary is not enough to decide your next step. Pass \`output: "<path>"\` when the deliverable itself is a file (report, patch, doc): the child writes it there and cites it in its summary. On failure the child always returns the error inline.\n\nIMPORTANT: Subagent names are NOT bash commands. Always use the \`subagent\` tool to delegate — never write scripts that call agent names as CLI commands.`;
205
205
  }
206
206
 
207
207
  /**
@@ -127,6 +127,13 @@ export interface RunSubagentOptions {
127
127
  parentCeiling?: string[];
128
128
  /** Parent's restrictive (deny/ask) permission rules, inherited into the child's gate via env. */
129
129
  parentRules?: Array<{ permission: string; pattern: string; action: "deny" | "ask" }>;
130
+ /**
131
+ * Commissioned deliverable path (issue #32). When set, the child is
132
+ * instructed to write its main deliverable there (resolved against cwd
133
+ * when relative) and cite it in the outcome block. Independent from the
134
+ * automatic temp archive of the full transcript output.
135
+ */
136
+ outputPath?: string;
130
137
  resolvePi?: () => Promise<PiResolution> | PiResolution;
131
138
  runner?: ProcessRunner;
132
139
  fs?: ExecutorFs;
@@ -216,6 +223,31 @@ export function composeChildTools(
216
223
  return result.length > 0 ? result : "none";
217
224
  }
218
225
 
226
+ /**
227
+ * Marker separating the child's full transcript from its closing outcome
228
+ * block (issue #32). The child is instructed (see buildClosingContract) to
229
+ * end its final message with a `## Outcome` line followed by 2-3 lines.
230
+ */
231
+ export const OUTCOME_MARKER = "## Outcome";
232
+
233
+ /**
234
+ * Split the child's closing outcome block off the full output.
235
+ *
236
+ * Returns the text after the LAST marker line (case-insensitive, surrounding
237
+ * whitespace ignored), or undefined when the child omitted the block —
238
+ * older agents and error paths fall back to today's truncated inline.
239
+ */
240
+ export function extractOutcomeBlock(output: string): string | undefined {
241
+ const lines = output.split("\n");
242
+ let idx = -1;
243
+ lines.forEach((line, i) => {
244
+ if (line.trim().toLowerCase() === OUTCOME_MARKER.toLowerCase()) idx = i;
245
+ });
246
+ if (idx === -1) return undefined;
247
+ const rest = lines.slice(idx + 1).join("\n").trim();
248
+ return rest ? rest : undefined;
249
+ }
250
+
219
251
  export function subagentSessionDir(
220
252
  cwd: string,
221
253
  agentDir = path.join(os.homedir(), ".pi", "agent"),
@@ -620,12 +652,25 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
620
652
  options.onProgress?.(progress(status));
621
653
 
622
654
  try {
655
+ // ── Commissioned deliverable (issue #32) ──
656
+ // Resolved against the child cwd so the closing contract can name the
657
+ // exact path and the task can point at it.
658
+ const deliverablePath =
659
+ options.outputPath && options.outputPath.trim()
660
+ ? path.resolve(options.cwd, options.outputPath)
661
+ : undefined;
662
+ const task =
663
+ deliverablePath !== undefined
664
+ ? `${options.task}\n\nDeliverable: save your main deliverable to ${deliverablePath} and cite it in the outcome block.`
665
+ : options.task;
666
+
623
667
  // ── Build system prompt ──
624
668
  const promptFilePath = path.join(tempDir, "system-prompt.md");
625
669
  const promptResult = await buildSubagentPrompt({
626
670
  agent: options.agent,
627
671
  cwd: options.cwd,
628
672
  agentDir: options.agentDir,
673
+ outputPath: deliverablePath,
629
674
  });
630
675
 
631
676
  for (const source of promptResult.skills.skippedPackages) {
@@ -641,9 +686,9 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
641
686
 
642
687
  // ── Write task to file if too long ──
643
688
  let taskFilePath: string | undefined;
644
- if (options.task.length > TASK_FILE_THRESHOLD) {
689
+ if (task.length > TASK_FILE_THRESHOLD) {
645
690
  taskFilePath = path.join(tempDir, "task.md");
646
- await fileSystem.writeFile(taskFilePath, options.task);
691
+ await fileSystem.writeFile(taskFilePath, task);
647
692
  }
648
693
 
649
694
  // ── Resolve pi CLI entry point ──
@@ -701,7 +746,7 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
701
746
  }
702
747
 
703
748
  // Task
704
- args.push(buildTaskArgument(options.task, taskFilePath));
749
+ args.push(buildTaskArgument(task, taskFilePath));
705
750
 
706
751
  // ── Build env vars ──
707
752
  const envVars: SubagentEnvVars = {
@@ -810,16 +855,38 @@ export async function runSubagent(options: RunSubagentOptions): Promise<AgentRes
810
855
  output = stderr || `Subagent exited with code ${exit.exitCode}`;
811
856
  }
812
857
 
813
- // ── Truncate large output ──
814
- const truncated = truncateHeadContent(output, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES);
815
- if (truncated !== undefined) {
816
- const originalOutput = output;
817
- const fullOutputPath = path.join(
818
- options.outputArchiveDir ?? os.tmpdir(),
819
- `${safeFilePart(options.agent.name)}-${startedAt}-output.md`,
858
+ // ── Archive full output + derive the inline result (issue #32) ──
859
+ // Every run archives the verbatim output to a temp file; inline the
860
+ // child only sends its closing outcome block (file reference included).
861
+ // Without the block (older agents, error paths) fall back to today's
862
+ // truncated inline. A failed archive write must never break delegation:
863
+ // fall back to inline-only and warn.
864
+ const fullOutputPath = path.join(
865
+ options.outputArchiveDir ?? os.tmpdir(),
866
+ `${safeFilePart(options.agent.name)}-${startedAt}-output.md`,
867
+ );
868
+ let archived = false;
869
+ try {
870
+ await fileSystem.writeFile(fullOutputPath, output);
871
+ archived = true;
872
+ } catch (error) {
873
+ console.warn(
874
+ `[pi-open-agents] could not archive subagent output to ${fullOutputPath}: ${error instanceof Error ? error.message : String(error)}`,
820
875
  );
821
- await fileSystem.writeFile(fullOutputPath, originalOutput);
822
- output = `${truncated}\n\n[Output truncated: original ${originalOutput.split("\n").length} lines / ${byteLength(originalOutput)} bytes. Full output: ${fullOutputPath}]`;
876
+ }
877
+ const outcome = extractOutcomeBlock(output);
878
+ if (archived && outcome !== undefined) {
879
+ output = `${outcome}\n\nFull output: ${fullOutputPath}`;
880
+ } else if (archived) {
881
+ const truncated = truncateHeadContent(output, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES);
882
+ if (truncated !== undefined) {
883
+ output = `${truncated}\n\n[Output truncated: original ${output.split("\n").length} lines / ${byteLength(output)} bytes. Full output: ${fullOutputPath}]`;
884
+ }
885
+ } else {
886
+ const truncated = truncateHeadContent(output, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES);
887
+ if (truncated !== undefined) {
888
+ output = `${truncated}\n\n[Output truncated: original ${output.split("\n").length} lines / ${byteLength(output)} bytes. Archive write failed — full output unavailable.]`;
889
+ }
823
890
  }
824
891
 
825
892
  const result: AgentResult = {
@@ -13,6 +13,12 @@ export interface BuildPromptOptions {
13
13
  agent: AgentDefinition;
14
14
  cwd: string;
15
15
  agentDir?: string;
16
+ /**
17
+ * Commissioned deliverable path (issue #32). When set, the child is told
18
+ * to write its main deliverable there and cite it in the outcome block.
19
+ * Already resolved against the child cwd by the caller.
20
+ */
21
+ outputPath?: string;
16
22
  }
17
23
 
18
24
  export interface BuildPromptResult {
@@ -48,10 +54,47 @@ function formatSkillsForPrompt(
48
54
  return lines.join("\n");
49
55
  }
50
56
 
57
+ /**
58
+ * Child-side result contract (issue #32).
59
+ *
60
+ * Every run archives the full output to a temp file and returns only a
61
+ * short outcome inline — the child must therefore END its final message
62
+ * with a brief outcome block after a `## Outcome` marker line, cite
63
+ * commissioned files as `` `<path>` — <one-line description> '' ›, and keep
64
+ * errors inline (never behind a file reference).
65
+ */
66
+ export function buildClosingContract(outputPath?: string): string {
67
+ const lines = [
68
+ "",
69
+ "",
70
+ "## Result Contract",
71
+ "",
72
+ "When your task is complete, end your final message with a short outcome block,",
73
+ "separated from the rest by a line containing exactly `## Outcome`:",
74
+ "",
75
+ "## Outcome",
76
+ "Outcome: <one line — done / failed + why>",
77
+ "Details: <what matters for the caller's next step, 2-3 lines>",
78
+ "",
79
+ "If you produced files the caller asked for, list each on its own line as",
80
+ "`<path>` — <one line describing the content>.",
81
+ "Keep the outcome block to 2-3 lines. Keep the rest of your answer concise — the",
82
+ "outcome block is what the caller reads first; never hide an error behind brevity.",
83
+ ];
84
+ if (outputPath) {
85
+ lines.push(
86
+ "",
87
+ `The caller expects its deliverable at \`${outputPath}\` — write it there with your file tools`,
88
+ "and cite it in the outcome block.",
89
+ );
90
+ }
91
+ return lines.join("\n");
92
+ }
51
93
  /**
52
94
  * Build the system prompt for a subagent.
53
95
  *
54
- * Starts with the agent body, appends skill injection if the agent has skills.
96
+ * Starts with the agent body, appends skill injection if the agent has skills,
97
+ * then the result-closing contract (issue #32).
55
98
  */
56
99
  export async function buildSubagentPrompt(options: BuildPromptOptions): Promise<BuildPromptResult> {
57
100
  let prompt = options.agent.prompt;
@@ -75,5 +118,7 @@ export async function buildSubagentPrompt(options: BuildPromptOptions): Promise<
75
118
  }
76
119
  }
77
120
 
121
+ prompt = `${prompt}${buildClosingContract(options.outputPath)}`;
122
+
78
123
  return { prompt, skills: skillsResult };
79
124
  }
@@ -45,7 +45,7 @@ import {
45
45
  parentInheritedRules,
46
46
  type RecursionEnv,
47
47
  } from "./env.ts";
48
- import { preview, shortenPath, stringArg } from "../utils/format.ts";
48
+ import { byteLength, preview, shortenPath, stringArg } from "../utils/format.ts";
49
49
 
50
50
  // ─── Types ───────────────────────────────────────────────────────────────────
51
51
 
@@ -55,6 +55,12 @@ interface SubagentParams {
55
55
  cwd?: string;
56
56
  session?: SubagentSessionMode;
57
57
  model?: string;
58
+ /**
59
+ * Commissioned deliverable path (issue #32). The child writes its main
60
+ * deliverable there and cites it in the closing outcome; the full
61
+ * transcript is always archived to temp regardless.
62
+ */
63
+ output?: string;
58
64
  }
59
65
 
60
66
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -155,11 +161,41 @@ function toToolResult(result: AgentResult) {
155
161
 
156
162
  function toProgressResult(progress: AgentProgress) {
157
163
  return {
158
- content: [{ type: "text" as const, text: progress.output || "(running...)" }],
164
+ content: [{ type: "text" as const, text: liveProgressText(progress.output) }],
159
165
  details: progress,
160
166
  };
161
167
  }
162
168
 
169
+ /**
170
+ * Cap for live progress text (issue #32 follow-up).
171
+ *
172
+ * Every progress emit used to re-send the child's ENTIRE accumulated output:
173
+ * over a long run the same bytes go out N times (once per event, growing).
174
+ * Partial updates are transient (never persisted to the transcript — verified
175
+ * against live session JSONL), so the cap only trims live display/RPC traffic:
176
+ * the tail stays (what the child is doing *now*), the head is already in the
177
+ * temp archive. Final results are untouched (see toToolResult).
178
+ */
179
+ export const LIVE_PROGRESS_MAX_BYTES = 2048;
180
+
181
+ /** Tail a string to a byte budget (halving converge, multibyte-safe). */
182
+ export function tailTextByBytes(text: string, maxBytes: number): string {
183
+ if (byteLength(text) <= maxBytes) return text;
184
+ let take = Math.min(text.length, maxBytes);
185
+ let cut = text.slice(text.length - take);
186
+ while (byteLength(cut) > maxBytes && take > 0) {
187
+ take = Math.floor(take / 2);
188
+ cut = text.slice(text.length - take);
189
+ }
190
+ return cut;
191
+ }
192
+
193
+ function liveProgressText(output: string | undefined): string {
194
+ const text = output || "(running...)";
195
+ const tailed = tailTextByBytes(text, LIVE_PROGRESS_MAX_BYTES);
196
+ return tailed.length < text.length ? `[…earlier live output omitted…]\n${tailed}` : text;
197
+ }
198
+
163
199
  // ─── Styled Rendering ────────────────────────────────────────────────────────
164
200
 
165
201
  function styledPathArg(args: Record<string, unknown>, theme: ThemeLike, fallback?: string): string {
@@ -279,7 +315,7 @@ export function registerSubagentTool(
279
315
  name: "subagent",
280
316
  label: "Subagent",
281
317
  description: "Delegate a task to a named sub-agent running in an isolated pi process.",
282
- promptSnippet: "Delegate isolated tasks with subagent({ agent, task, cwd?, session?, model? }).",
318
+ promptSnippet: "Delegate isolated tasks with subagent({ agent, task, cwd?, session?, model?, output? }).",
283
319
  promptGuidelines,
284
320
  parameters: Type.Object({
285
321
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -295,6 +331,11 @@ export function registerSubagentTool(
295
331
  description: 'Per-call model override in "provider/model-id" format (e.g. "anthropic/claude-sonnet-4-5"). Model ids may contain slashes for aggregator providers ("openrouter/anthropic/claude-opus-5.5"). Takes precedence over the agent definition default.',
296
332
  }),
297
333
  ),
334
+ output: Type.Optional(
335
+ Type.String({
336
+ description: "Deliverable file path. The child writes its main deliverable there (relative paths resolve against the child cwd) and cites it in the closing outcome. The full transcript is always archived to temp regardless.",
337
+ }),
338
+ ),
298
339
  }),
299
340
 
300
341
  async execute(
@@ -356,6 +397,7 @@ export function registerSubagentTool(
356
397
  agentDir: options.agentDir,
357
398
  session,
358
399
  modelOverride: params.model,
400
+ outputPath: params.output && params.output.trim() ? params.output : undefined,
359
401
  parentCeiling,
360
402
  parentRules: parentRules.length > 0 ? parentRules : undefined,
361
403
  onProgress: (progress) => onUpdate?.(toProgressResult(progress)),