@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,288 @@
1
+ /** Resolve named preloaded skills through Pi's configured resource discovery. */
2
+
3
+ import { type Dirent, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { loadSkills, type ResolvedResource, SettingsManager, type SettingsManager as SettingsManagerType } from "@earendil-works/pi-coding-agent";
7
+ import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
8
+ import { createHomePackageManager } from "./resource-loader.js";
9
+
10
+ /** Discovery is inspect-only: missing sources are never installed or activated. */
11
+ const skipMissingSource = async (): Promise<"skip"> => "skip";
12
+
13
+ export interface PreloadedSkill {
14
+ name: string;
15
+ content: string;
16
+ }
17
+
18
+ export interface SkillPreloadOptions {
19
+ agentDir: string;
20
+ settingsManager: SettingsManagerType;
21
+ homeDir?: string;
22
+ }
23
+
24
+ /**
25
+ * Load named skills through Pi, retaining the preloader's legacy user roots.
26
+ *
27
+ * Pi owns configured package discovery, parsing, trust, and missing-package
28
+ * handling. A second untrusted manager exposes the user/package candidates
29
+ * which Pi's realpath collapse can hide behind a rejected project symlink.
30
+ */
31
+ export async function preloadSkills(
32
+ skillNames: string[],
33
+ cwd: string,
34
+ options: SkillPreloadOptions,
35
+ ): Promise<PreloadedSkill[]> {
36
+ if (skillNames.length === 0) return [];
37
+ const primary = createHomePackageManager({ cwd, agentDir: options.agentDir, settingsManager: options.settingsManager }, options.homeDir);
38
+ const primaryResources = await primary.resolve(skipMissingSource);
39
+ const fallbackSettings = SettingsManager.create(cwd, options.agentDir, { projectTrusted: false });
40
+ const fallback = createHomePackageManager({ cwd, agentDir: options.agentDir, settingsManager: fallbackSettings }, options.homeDir);
41
+ const fallbackResources = await fallback.resolve(skipMissingSource);
42
+
43
+ const rejectedProjectPaths = new Set(primaryResources.skills
44
+ .filter(resource => isTier(resource, "project") && isSymlinkedProjectSkill(resource, cwd))
45
+ .map(resource => canonicalPath(resource.path)));
46
+ // A path that cannot be canonicalized never matches, so it is never restored.
47
+ const isBehindRejectedAlias = (resource: ResolvedResource): boolean => {
48
+ const canonical = canonicalPath(resource.path);
49
+ return canonical !== undefined && rejectedProjectPaths.has(canonical);
50
+ };
51
+ // Package candidates collapsed behind a rejected project alias, resolved with
52
+ // Pi's own project package filters applied.
53
+ const aliasFreeSkills = rejectedProjectPaths.size === 0 ? [] : await resolveAliasFreePackageSkills(cwd, options);
54
+ const recoveredPackages = aliasFreeSkills.filter(isBehindRejectedAlias);
55
+
56
+ // Resolve native disabled paths through Pi's parser, preserving every filter form it supports.
57
+ const disabledNames = new Set(loadTier(
58
+ [...primaryResources.skills, ...fallbackResources.skills, ...recoveredPackages].filter(resource => !resource.enabled),
59
+ cwd,
60
+ options.agentDir,
61
+ ).map(skill => skill.name));
62
+ const project = discoveredSkills(
63
+ primaryResources.skills.filter(resource => resource.enabled && isTier(resource, "project") && !isSymlinkedProjectSkill(resource, cwd)),
64
+ cwd,
65
+ options.agentDir,
66
+ );
67
+ const user = discoveredSkills(
68
+ [
69
+ ...primaryResources.skills,
70
+ // Only restore a user candidate Pi collapsed behind a rejected project alias.
71
+ // Other fallback user resources could bypass Pi's project filters.
72
+ ...fallbackResources.skills.filter(isBehindRejectedAlias),
73
+ ].filter(resource => resource.enabled && isTier(resource, "user") && !isAmbientUserAgentsSkill(resource, options.homeDir)),
74
+ cwd,
75
+ options.agentDir,
76
+ );
77
+ const packages = discoveredSkills(
78
+ [
79
+ ...primaryResources.skills,
80
+ // Only restore a package candidate Pi collapsed behind a rejected project alias.
81
+ ...recoveredPackages,
82
+ ].filter(resource => resource.enabled && isTier(resource, "package") && !isAmbientUserAgentsSkill(resource, options.homeDir)),
83
+ cwd,
84
+ options.agentDir,
85
+ );
86
+
87
+ // Historical user roots, including their raw top-level `<name>.md` lookup.
88
+ const home = resolve(options.homeDir ?? homedir());
89
+ const legacyUserRoots = [join(options.agentDir, "skills"), join(home, ".agents", "skills"), join(home, ".pi", "skills")];
90
+ return skillNames.map(name => {
91
+ const native = project.get(name) ?? user.get(name) ?? packages.get(name);
92
+ const content = native
93
+ ?? (disabledNames.has(name)
94
+ ? undefined
95
+ : (options.settingsManager.isProjectTrusted() ? findInRoots(projectSkillRoots(cwd).map(root => join(root, "skills")), name, false) : undefined)
96
+ ?? findInRoots(legacyUserRoots, name, true));
97
+ return { name, content: content ?? `(Skill "${name}" not found in Pi's configured skill resources)` };
98
+ });
99
+ }
100
+
101
+ /** Precedence tier of a resolved skill: top-level project or user resources, or package resources. */
102
+ function isTier(resource: ResolvedResource, tier: "project" | "user" | "package"): boolean {
103
+ return tier === "package" ? resource.metadata.origin === "package" : resource.metadata.origin === "top-level" && resource.metadata.scope === tier;
104
+ }
105
+
106
+ /** Absolutize an existing local project package source, which Pi resolves against `<cwd>/.pi`. */
107
+ function absolutizeProjectSource(source: string, cwd: string): string {
108
+ const local = resolve(cwd, ".pi", source);
109
+ return !source.startsWith("npm:") && existsSync(local) ? local : source;
110
+ }
111
+
112
+ /**
113
+ * Resolve package skills as Pi would, minus the project's top-level skill dirs.
114
+ *
115
+ * Pi collapses resources by realpath, so a rejected project symlink can hide a
116
+ * package skill together with the project package filter that applies to it.
117
+ * Resolving from an empty scratch cwd, with the real project settings and
118
+ * npm/git install roots carried over, keeps Pi as the owner of those filters.
119
+ */
120
+ async function resolveAliasFreePackageSkills(cwd: string, options: SkillPreloadOptions): Promise<ResolvedResource[]> {
121
+ const scratch = mkdtempSync(join(tmpdir(), "skill-preload-"));
122
+ try {
123
+ for (const dir of ["npm", "git"]) {
124
+ const real = join(cwd, ".pi", dir);
125
+ if (!existsSync(real)) continue;
126
+ mkdirSync(join(scratch, ".pi"), { recursive: true });
127
+ symlinkSync(real, join(scratch, ".pi", dir), "dir");
128
+ }
129
+ const project = options.settingsManager.getProjectSettings();
130
+ const settings = {
131
+ global: options.settingsManager.getGlobalSettings(),
132
+ project: { ...project, packages: project.packages?.map(pkg => typeof pkg === "string"
133
+ ? absolutizeProjectSource(pkg, cwd)
134
+ : { ...pkg, source: absolutizeProjectSource(pkg.source, cwd) }) },
135
+ };
136
+ // Pi's public storage seam replaces the scratch cwd's settings with the real
137
+ // ones. `normalizeHomeResourceSettings` cannot: it only rewrites a manager's
138
+ // own settings, and `createHomePackageManager` still applies it on top.
139
+ const settingsManager = SettingsManager.fromStorage(
140
+ { withLock: (scope, read) => void read(JSON.stringify(settings[scope])) },
141
+ { projectTrusted: options.settingsManager.isProjectTrusted() },
142
+ );
143
+ const manager = createHomePackageManager({ cwd: scratch, agentDir: options.agentDir, settingsManager }, options.homeDir);
144
+ return (await manager.resolve(skipMissingSource)).skills.filter(resource => resource.metadata.origin === "package");
145
+ } finally {
146
+ rmSync(scratch, { recursive: true, force: true });
147
+ }
148
+ }
149
+
150
+ /** Parse resolved skill paths with Pi's own loader and validation. */
151
+ function loadTier(resources: ResolvedResource[], cwd: string, agentDir: string): ReturnType<typeof loadSkills>["skills"] {
152
+ return loadSkills({ cwd, agentDir, skillPaths: resources.map(resource => resource.path), includeDefaults: false }).skills;
153
+ }
154
+
155
+ /** Load Pi-valid skills from a precedence tier, retaining the first declared name. */
156
+ function discoveredSkills(resources: ResolvedResource[], cwd: string, agentDir: string): Map<string, string> {
157
+ return new Map(loadTier(resources, cwd, agentDir).map(skill => [skill.name, readFileSync(skill.filePath, "utf-8").trim()]));
158
+ }
159
+
160
+ /** Find a named legacy skill under roots in order without broadening Pi discovery. */
161
+ function findInRoots(roots: string[], name: string, trusted: boolean): string | undefined {
162
+ if (isUnsafeName(name)) return undefined;
163
+ for (const root of roots) {
164
+ const content = findInRoot(root, name, trusted);
165
+ if (content !== undefined) return content;
166
+ }
167
+ return undefined;
168
+ }
169
+
170
+ /** Match the former preloader's flat-file and recursive Agent Skills lookup. */
171
+ function findInRoot(root: string, name: string, trusted: boolean): string | undefined {
172
+ if (!trusted && isPathThroughSymlink(dirname(root), root)) return undefined;
173
+ const flat = readSkillFile(join(root, `${name}.md`), trusted)?.trim();
174
+ if (flat !== undefined) return flat;
175
+ if (!existsSync(root)) return undefined;
176
+
177
+ const queue = [root];
178
+ const seen = new Set<string>();
179
+ // `root` passed the symlink check above and every queued directory passed it before it was
180
+ // pushed, so `current` needs no second check. Iteration visits entries pushed meanwhile, in order.
181
+ for (const current of queue) {
182
+ if (trusted) {
183
+ const canonical = canonicalPath(current);
184
+ // A directory raced away since it was queued has nothing left to read.
185
+ if (canonical === undefined || seen.has(canonical)) continue;
186
+ seen.add(canonical);
187
+ }
188
+ let entries: Dirent<string>[];
189
+ try {
190
+ entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => Number(left.name > right.name) - Number(left.name < right.name));
191
+ } catch {
192
+ continue;
193
+ }
194
+ for (const entry of entries) {
195
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
196
+ const path = join(current, entry.name);
197
+ if (!trusted && isPathThroughSymlink(dirname(root), path)) continue;
198
+ const isDirectory = entry.isDirectory() || (trusted && entry.isSymbolicLink() && isDirectoryTarget(path));
199
+ if (!isDirectory) continue;
200
+ const skillFile = join(path, "SKILL.md");
201
+ if (existsSync(skillFile)) {
202
+ const content = entry.name === name ? readSkillFile(skillFile, trusted)?.trim() : undefined;
203
+ if (content !== undefined) return content;
204
+ continue;
205
+ }
206
+ queue.push(path);
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ function readSkillFile(path: string, trusted: boolean): string | undefined {
213
+ if (!trusted) return safeReadFile(path);
214
+ try {
215
+ return statSync(path).isFile() ? readFileSync(path, "utf-8") : undefined;
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ function isDirectoryTarget(path: string): boolean {
222
+ try {
223
+ return statSync(path).isDirectory();
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Match Pi's realpath identity without importing its private path utility.
231
+ *
232
+ * Unlike Pi, an uncanonicalizable path (raced away, EACCES) yields undefined
233
+ * rather than the raw path, so it can never match a rejected alias. That is
234
+ * also why `realOrSelf` in `package-resources.ts` is not reused: it falls back
235
+ * to the realpath of the nearest existing parent.
236
+ */
237
+ function canonicalPath(path: string): string | undefined {
238
+ try {
239
+ return realpathSync(path);
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ /** Exclude Pi's ambient user root when a caller supplies a distinct injected home. */
246
+ export function isAmbientUserAgentsSkill(resource: ResolvedResource, homeDir?: string, runtimeHome = homedir()): boolean {
247
+ if (homeDir === undefined || resource.metadata.scope !== "user" || resource.metadata.source !== "auto") return false;
248
+ const injectedRoot = join(resolve(homeDir), ".agents", "skills");
249
+ return !isPathWithin(injectedRoot, resource.path) && isPathWithin(join(runtimeHome, ".agents", "skills"), resource.path);
250
+ }
251
+
252
+ /** Reject project symlinks before accepting their resources or resolving collisions. Callers pass project-tier resources only. */
253
+ function isSymlinkedProjectSkill(resource: ResolvedResource, cwd: string): boolean {
254
+ return projectSkillRoots(cwd).some(root => isPathThroughSymlink(root, resource.path));
255
+ }
256
+
257
+ /** Match Pi's project skill roots while checking their parent directories too. */
258
+ function projectSkillRoots(cwd: string): string[] {
259
+ const roots = [join(cwd, ".pi")];
260
+ let directory = resolve(cwd);
261
+ while (true) {
262
+ roots.push(join(directory, ".agents"));
263
+ if (existsSync(join(directory, ".git"))) break;
264
+ const parent = dirname(directory);
265
+ if (parent === directory) break;
266
+ directory = parent;
267
+ }
268
+ return roots;
269
+ }
270
+
271
+ /** Textual containment. Unlike `isInside` in `package-resources.ts`, it must not canonicalize: that would erase the symlinks callers look for. */
272
+ function isPathWithin(root: string, path: string): boolean {
273
+ const relativePath = relative(resolve(root), resolve(path));
274
+ return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath));
275
+ }
276
+
277
+ function isPathThroughSymlink(root: string, path: string): boolean {
278
+ const absoluteRoot = resolve(root);
279
+ if (!isPathWithin(absoluteRoot, path)) return false;
280
+ const relativePath = relative(absoluteRoot, resolve(path));
281
+ let current = absoluteRoot;
282
+ if (isSymlink(current)) return true;
283
+ for (const segment of relativePath.split(sep)) {
284
+ current = join(current, segment);
285
+ if (isSymlink(current)) return true;
286
+ }
287
+ return false;
288
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * stale-ctx.ts -- recognising pi's "extension ctx is stale" failure.
3
+ *
4
+ * pi invalidates an extension runner on reload and on session replacement
5
+ * (/new, /fork, /resume). Anything that still reaches the old runner or its
6
+ * `pi` throws this message (#145).
7
+ */
8
+
9
+ /** Matched on the stable prefix of core's message. */
10
+ export const STALE_CTX_RE = /extension ctx is stale after session replacement or reload/i;
11
+
12
+ /** Whether `error` (an `Error` or a message string) is the stale-ctx failure. */
13
+ export function isStaleCtxError(error: unknown): boolean {
14
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
15
+ return STALE_CTX_RE.test(message);
16
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * startup-error.ts — bridge to the host SDK's optional `ExtensionStartupError`.
3
+ *
4
+ * A `session_start` handler that throws `ExtensionStartupError` makes the host
5
+ * fail the bind (no provider request, nonzero exit in the CLI modes). Hosts
6
+ * predating that class export nothing of the sort, and this package's peer
7
+ * range still admits them, so the class cannot be a static named import: on an
8
+ * older host the import would fail to link and take the whole extension down
9
+ * with an opaque error. The namespace is probed at runtime instead, and callers
10
+ * that need the fatal path refuse clearly when it is absent.
11
+ */
12
+
13
+ import * as hostSdk from "@earendil-works/pi-coding-agent";
14
+
15
+ /** Shape of the host's `ExtensionStartupError`: a normal `Error` constructor. */
16
+ export type StartupErrorConstructor = new (message: string, options?: ErrorOptions) => Error;
17
+
18
+ function isStartupErrorConstructor(value: unknown): value is StartupErrorConstructor {
19
+ return typeof value === "function" && value.prototype instanceof Error;
20
+ }
21
+
22
+ /**
23
+ * The host's `ExtensionStartupError` class, or `undefined` on a host that does
24
+ * not export one. `sdk` defaults to the real host module; tests pass a stand-in.
25
+ */
26
+ export function hostStartupErrorClass(sdk: object = hostSdk): StartupErrorConstructor | undefined {
27
+ const candidate: unknown = Reflect.get(sdk, "ExtensionStartupError");
28
+ return isStartupErrorConstructor(candidate) ? candidate : undefined;
29
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * status-note.ts — Honest framing for an agent result: the parenthetical status
3
+ * note for a non-normal outcome, and the salvaged partial output of a failure.
4
+ *
5
+ * Lives here rather than in an index.ts closure because both entry points need
6
+ * it — the top-level tools and the nested delegation tools, which can't import
7
+ * from index.ts (that is the extension entry, and it already reaches these tools
8
+ * through agent-runner).
9
+ */
10
+
11
+ import type { AgentRecord } from "./types.js";
12
+
13
+ /** Who stopped a `stopped` record: the spawning agent, or (unset) a human. */
14
+ type StoppedBy = AgentRecord["stoppedBy"];
15
+
16
+ /**
17
+ * Explicit parenthetical note for a non-normal terminal outcome, so the parent
18
+ * agent can't mistake partial output for a completed result. Empty string for a
19
+ * clean completion (and any unknown/non-terminal status).
20
+ *
21
+ * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
22
+ * turn limit was hit) — the parent should treat human intervention differently
23
+ * from a budget cutoff. A stop the parent itself made with `stop_subagent`
24
+ * (`stoppedBy: "parent"`) is stated plainly instead: shouting a human
25
+ * intervention that never happened would misreport its own decision back to it.
26
+ */
27
+ export function getStatusNote(status: string, stoppedBy?: StoppedBy): string {
28
+ switch (status) {
29
+ case "stopped":
30
+ return stoppedBy === "parent"
31
+ ? " (stopped by the parent with stop_subagent before completion — output is partial; the task was NOT finished)"
32
+ : " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
33
+ case "aborted":
34
+ return " (aborted — hit the turn limit before completion; output may be incomplete)";
35
+ case "steered":
36
+ return " (wrapped up at the turn limit — output may be partial)";
37
+ default:
38
+ return "";
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Foreground variant of `getStatusNote`. A foreground caller is in a different
44
+ * position from a background one, so it needs different text:
45
+ *
46
+ * - It already holds the agent's ENTIRE output inline, whereas the background
47
+ * notification carries a 500-char preview. So only here can we truthfully
48
+ * say there is nothing more to fetch — which is the whole point, because
49
+ * - it has no agent id. The id travels in the tool result's renderer
50
+ * `details`, which is never serialized to the model. A parent that reads
51
+ * "output may be partial" as "truncated, go retrieve the rest" therefore
52
+ * has nothing valid to call `get_subagent_result` with, and will invent an
53
+ * id (#174).
54
+ *
55
+ * Only the lead clause varies between the three, and each variation carries
56
+ * information: `wrapped up` vs `aborted` tells the parent whether the output is
57
+ * a considered final answer or a fragment, and `stopped` shouts because a human
58
+ * intervening outranks everything else in the string. Only `steered` hedges on
59
+ * completion — it was told to wrap up and did, so it may well have finished at
60
+ * the limit; an aborted run blew through its grace turns while still working,
61
+ * and `stopped` can only fire on a running agent, so neither ever delivered a
62
+ * final answer. Identical confidence gets identical wording: phrasing one fact
63
+ * two ways invites a hunt for a distinction that isn't there.
64
+ *
65
+ * Every clause is a statement about state, never an instruction to act, and
66
+ * `get_subagent_result` is never named — naming the tool we steer away from only
67
+ * raises its salience. Two instructions were tried here and cut: "re-spawn with
68
+ * a higher max_turns" (pushes a fresh multi-minute run to save one wasted tool
69
+ * call) and, on `stopped`, "ask before restarting it" (restates the lead, and
70
+ * presumes someone is present to ask — false under `pi -p`, in scheduled jobs,
71
+ * and in any background-driven run). Nothing here can measure whether wording
72
+ * improves parent behavior, so removing a false cue (which cannot induce new
73
+ * behavior) and adding an instruction (which can) are not equally safe bets.
74
+ * Don't add either back without a way to measure it.
75
+ */
76
+ export function getForegroundOutcomeNote(status: string, stoppedBy?: StoppedBy): string {
77
+ switch (status) {
78
+ case "stopped":
79
+ return stoppedBy === "parent"
80
+ ? " (stopped by the parent with stop_subagent — everything the agent produced is above; the task is unfinished)"
81
+ : " (STOPPED BY THE USER — everything the agent produced is above; the task is unfinished)";
82
+ case "aborted":
83
+ return " (aborted at the turn limit — everything the agent produced is above; the task is unfinished)";
84
+ case "steered":
85
+ return " (wrapped up at the turn limit — everything the agent produced is above; the task may be unfinished)";
86
+ default:
87
+ return "";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * For an `error` run whose session survived, the hint that resuming it
93
+ * continues the work, naming the agent; "" otherwise. A provider or auth
94
+ * failure (e.g. a credential helper that failed transiently, #456) ends the
95
+ * turn but keeps the whole transcript, so the parent should resume the agent
96
+ * rather than re-spawn it and repay its context. A run that never got a
97
+ * session has nothing to resume.
98
+ */
99
+ export function resumeHint(record: Pick<AgentRecord, "id" | "status" | "session">): string {
100
+ return record.status === "error" && record.session
101
+ ? `transcript intact; resume agent ${record.id} to continue`
102
+ : "";
103
+ }
104
+
105
+ /**
106
+ * Salvaged partial output of a failed run, as a labeled suffix for the error
107
+ * surfaces (or "" if the run produced nothing). `record.result` is bounded to
108
+ * the run's own turns, so this is never a stale earlier answer (#144).
109
+ */
110
+ export function partialOutputSuffix(record: AgentRecord): string {
111
+ const partial = record.result?.trim();
112
+ return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
113
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * structured-output.ts — the synthetic tool behind `agent(prompt, { schema })`.
3
+ *
4
+ * A workflow script that passes a `schema` wants an *object* back, not prose it
5
+ * has to parse. Claude Code does this by giving the child a `StructuredOutput`
6
+ * tool whose input schema is the caller's schema, so the provider fills the
7
+ * fields, and returning the validated payload as the agent's result.
8
+ *
9
+ * We do the same, with one gap named up front: Claude Code *forces* the call,
10
+ * and we cannot. `toolChoice` exists in pi-ai's provider layer but is not
11
+ * plumbed through `AgentSession`, so an extension has no way to require a
12
+ * particular tool. What we have instead is three softer pressures —
13
+ *
14
+ * 1. `constrainedSampling`, so providers that support it hold the payload to
15
+ * the schema at sampling time;
16
+ * 2. the tool's description, snippet and guideline, which say the answer must
17
+ * come through this call;
18
+ * 3. validation here, answering a bad payload with `isError` so the model
19
+ * sees what was wrong and calls again inside the same run.
20
+ *
21
+ * — and, when all three fail, one more prompt from `runAgent`. See
22
+ * {@link structuredRetryPrompt}.
23
+ *
24
+ * The name matches Claude Code's exactly, so a ported prompt that mentions
25
+ * `StructuredOutput` is still telling the truth.
26
+ */
27
+
28
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
29
+ import type { CompiledSchema } from "./workflow/json-schema.js";
30
+
31
+ /**
32
+ * Deliberately NOT added to `SUBAGENT_TOOL_NAMES`: that list becomes
33
+ * `EXCLUDED_TOOL_NAMES`, which is exactly the denial this tool has to avoid.
34
+ * Nor to `BUILTIN_TOOL_NAMES` — it is ours to inject, never a name a user may
35
+ * ask for in an agent's `tools:` frontmatter.
36
+ */
37
+ export const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput";
38
+
39
+ /** What the child produced, filled in as the tool is called. */
40
+ export interface StructuredCapture {
41
+ /** The last payload that validated, canonicalised. Absent until one does. */
42
+ json?: string;
43
+ /** Why the most recent attempt was rejected, for the retry prompt. */
44
+ lastError?: string;
45
+ /** Whether the tool was called at all — "never tried" reads differently. */
46
+ called: boolean;
47
+ }
48
+
49
+ export function createStructuredCapture(): StructuredCapture {
50
+ return { called: false };
51
+ }
52
+
53
+ /**
54
+ * Build the tool for one child.
55
+ *
56
+ * `capture` is the box the caller reads afterwards. It is passed in rather than
57
+ * returned so `runAgent` owns its lifetime and can consult it on every exit
58
+ * path, including the ones where the tool was never reached.
59
+ */
60
+ export function createStructuredOutputTool(
61
+ compiled: CompiledSchema,
62
+ capture: StructuredCapture,
63
+ ): ToolDefinition {
64
+ return defineTool({
65
+ name: STRUCTURED_OUTPUT_TOOL_NAME,
66
+ label: "Structured Output",
67
+ description:
68
+ "Report your final answer. Call this exactly once, with the complete result, and put everything the "
69
+ + "caller needs inside the arguments — text written outside this call is discarded. If a call is "
70
+ + "rejected for not matching the schema, fix the reported fields and call it again.",
71
+ promptSnippet: "Report your final answer as structured data",
72
+ promptGuidelines: [
73
+ "Your final answer MUST be reported by calling StructuredOutput. Prose outside that call is discarded.",
74
+ ],
75
+ // The caller's schema *is* the tool's input schema, verbatim — that is what
76
+ // makes the provider fill the fields. pi types this as TypeBox's `TSchema`,
77
+ // which v1 defines as an open interface, so a plain JSON Schema satisfies
78
+ // it without a cast at runtime or a conversion at author time.
79
+ parameters: compiled.schema as never,
80
+ // "prefer", not "require": a provider that cannot constrain sampling should
81
+ // fall through to validation-and-retry rather than fail the call outright.
82
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
83
+ // Models occasionally send the whole payload as one JSON string instead of
84
+ // an object. Recovering that costs nothing and saves a whole retry.
85
+ prepareArguments: (args: unknown) => {
86
+ if (typeof args !== "string") return args as never;
87
+ try {
88
+ return JSON.parse(args) as never;
89
+ } catch {
90
+ return args as never;
91
+ }
92
+ },
93
+ execute: async (_toolCallId, params) => {
94
+ capture.called = true;
95
+ const verdict = compiled.check(params);
96
+ if (verdict !== true) {
97
+ capture.lastError = verdict;
98
+ // `isError` puts the reason in front of the model as a tool result, so
99
+ // it can correct itself inside this same run. This is where most
100
+ // mismatches are resolved; the prompt-level retry is the backstop.
101
+ return {
102
+ content: [{
103
+ type: "text",
104
+ text: `StructuredOutput did not match the required schema:\n${verdict}\nCall it again with a corrected value.`,
105
+ }],
106
+ isError: true,
107
+ details: {},
108
+ };
109
+ }
110
+ // Last valid call wins: a model that calls twice meant the second one.
111
+ capture.json = JSON.stringify(params);
112
+ capture.lastError = undefined;
113
+ return { content: [{ type: "text", text: "Recorded." }], details: {} };
114
+ },
115
+ }) as ToolDefinition;
116
+ }
117
+
118
+ /**
119
+ * The one extra prompt sent when a run ended with nothing captured.
120
+ *
121
+ * Distinguishes "never called it" from "called it wrongly" — the two need
122
+ * different corrections, and telling a model it got the shape wrong when it
123
+ * never answered at all sends it looking for a mistake it did not make.
124
+ */
125
+ export function structuredRetryPrompt(capture: StructuredCapture): string {
126
+ const reason = capture.called && capture.lastError !== undefined
127
+ ? `Your last ${STRUCTURED_OUTPUT_TOOL_NAME} call did not match the required schema: ${capture.lastError}`
128
+ : `You did not call ${STRUCTURED_OUTPUT_TOOL_NAME}, so your answer was not recorded.`;
129
+ return `${reason}\n\nCall ${STRUCTURED_OUTPUT_TOOL_NAME} now with your complete final answer. Do not reply with prose.`;
130
+ }