@tranhoangnguyen0310/pi-flow-external 5.1.0-external.0 → 5.2.0-external.0
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 +17 -0
- package/docs/supervision-and-runtime.md +19 -10
- package/package.json +10 -10
- package/src/config-modal.ts +7 -1
- package/src/contract/runs.ts +174 -2
- package/src/core/concurrency.ts +4 -1
- package/src/core/errors.ts +4 -1
- package/src/core/run-inspection.ts +1 -1
- package/src/core/run-registry.ts +6 -1
- package/src/external-command.ts +4 -1
- package/src/external-help.ts +3 -0
- package/src/external-runs.ts +231 -97
- package/src/pi-subagent.ts +43 -1
- package/src/prompts.ts +6 -7
- package/src/workflow/journal.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-flow external are documented here.
|
|
4
4
|
|
|
5
|
+
## [5.2.0-external.0] - 2026-10-20
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Requires Pi 1.3: peer dependencies on `@earendil-works/pi-agent-core`, `pi-ai`, `pi-coding-agent` and `pi-tui` move from `~0.99.2` to `~1.3.0`.
|
|
9
|
+
- Node 26 is supported and tested in CI. `npm run e2e` no longer passes `--experimental-transform-types`, which Node 26 removed; the sources now use erasable TypeScript syntax only and load through Node's built-in type stripping.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `external_delegate` and `external_workflow` no longer disappear from a session saved before the #85 rename. Pi restores a resumed or tree-navigated session's tool loadout from its transcript, and the legacy names `Agent` and `workflow` no longer register. Each legacy name the restored loadout declares now activates its replacement. A launcher stays off when the loadout also declares its current name, or when `--tools -<name>` or `--no-tools` excludes it.
|
|
13
|
+
|
|
14
|
+
## [5.1.1-external.0] - 2026-10-20
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Simplified `external_runs` action-specific argument guidance (#87):
|
|
18
|
+
- Consolidated `externalRunsParameters`, `EXTERNAL_RUNS_PARAMETER_KEYS`, `EXTERNAL_RUNS_EXAMPLES`, `EXTERNAL_RUNS_ACTION_SPECS`, and `formatExternalRunsActionGuidance()` in `src/contract/runs.ts` so schema descriptions, on-demand `external_help` (`usage` and `workflow`), and documentation share one authoritative per-action argument matrix (`required`, `optional`, and dynamically derived `inapplicable` fields).
|
|
19
|
+
- Clarified single-target `inspect` (`1` ID, views `summary|output|diagnostics|final|launch`) vs. batch `inspect` (`2-20` IDs, `summary` view only), and `cursor` (`runs` stream) vs. `workflowCursor` (`workflows` stream, inapplicable when `workflowRunId` filters to one workflow's children).
|
|
20
|
+
- Updated `request_invalid` and `cursor_invalid` error messages across `list`, `inspect`, `wait`, and `cancel` to identify the applicable action and include a minimal valid JSON call example while preserving #62 forced-placeholder normalization and supervision invariants.
|
|
21
|
+
|
|
5
22
|
## [5.1.0-external.0] - 2026-10-19
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -108,27 +108,37 @@ return { run, view, encoding, text, checklist };
|
|
|
108
108
|
|
|
109
109
|
## The `external_runs` Tool
|
|
110
110
|
|
|
111
|
-
Use `external_runs` to supervise, inspect, and manage runs programmatically:
|
|
111
|
+
Use `external_runs` to supervise, inspect, and manage runs programmatically. Only `action` is top-level required in the JSON Schema; omit parameters that do not apply to the chosen action:
|
|
112
|
+
|
|
113
|
+
| Action | Required | Optional | Inapplicable (omit) |
|
|
114
|
+
| --- | --- | --- | --- |
|
|
115
|
+
| `list` | `action` | `workflowRunId`, `cursor`, `workflowCursor`, `limit` | `runIds`, `view`, `limitBytes`, `mode`, `reason` |
|
|
116
|
+
| `list` (with `workflowRunId`) | `action`, `workflowRunId` | `cursor`, `limit` | `workflowCursor`, `runIds`, `view`, `limitBytes`, `mode`, `reason` |
|
|
117
|
+
| `inspect` (single run: 1 ID) | `action`, `runIds` (`[id]`) | `view` (`summary` \| `output` \| `diagnostics` \| `final` \| `launch`), `cursor`, `limitBytes` | `workflowRunId`, `workflowCursor`, `limit`, `mode`, `reason` |
|
|
118
|
+
| `inspect` (batch: 2-20 IDs) | `action`, `runIds` (`2-20` IDs) | `view` (`"summary"` only), `cursor`, `limitBytes` | `workflowRunId`, `workflowCursor`, `limit`, `mode`, `reason` |
|
|
119
|
+
| `wait` | `action`, `runIds` (`1-100` IDs) | `mode` (`"any"` \| `"all"`), `limitBytes` | `workflowRunId`, `view`, `cursor`, `workflowCursor`, `limit`, `reason` |
|
|
120
|
+
| `cancel` | `action`, `runIds` (`[id]`, `1` ID) | `reason` | `workflowRunId`, `view`, `cursor`, `workflowCursor`, `limit`, `limitBytes`, `mode` |
|
|
112
121
|
|
|
113
122
|
### 1. `list`
|
|
114
|
-
Page session-owned runs and workflow roots:
|
|
123
|
+
Page session-owned agent runs and workflow roots:
|
|
115
124
|
```ts
|
|
116
125
|
await external_runs({
|
|
117
126
|
action: "list",
|
|
118
127
|
limit: 10,
|
|
119
|
-
cursor: undefined, // pass nextCursor for pagination
|
|
120
128
|
});
|
|
121
129
|
```
|
|
122
|
-
|
|
130
|
+
- **`cursor` vs. `workflowCursor`:** `list` returns two independent paged streams: `runs` (paged with `cursor` from `nextCursor`) and `workflows` (paged with `workflowCursor` from `nextWorkflowCursor`).
|
|
131
|
+
- **`workflowRunId` filtering:** Passing `workflowRunId: "wf_..."` filters `runs` to that workflow's child agent runs and returns `workflows: []`, so only `cursor` applies (`workflowCursor` is inapplicable).
|
|
132
|
+
- Each entry returns timing projections (`queueDelayMs`, `elapsedMs`, `activityAgeMs`, `processDurationMs`).
|
|
123
133
|
|
|
124
134
|
### 2. `inspect`
|
|
125
135
|
Inspect a specific run's state and streams:
|
|
126
136
|
```ts
|
|
127
|
-
// Single-run inspection supports any view:
|
|
137
|
+
// Single-run inspection (1 ID) supports any view:
|
|
128
138
|
await external_runs({
|
|
129
139
|
action: "inspect",
|
|
130
140
|
runIds: [runId],
|
|
131
|
-
view: "
|
|
141
|
+
view: "final", // "summary" | "output" | "diagnostics" | "final" | "launch"
|
|
132
142
|
limitBytes: 32768,
|
|
133
143
|
});
|
|
134
144
|
```
|
|
@@ -138,17 +148,16 @@ await external_runs({
|
|
|
138
148
|
- `final`: **Verified canonical result only**. Returns empty with `finalAvailable: false` until the child reaches verified terminal success.
|
|
139
149
|
- `launch`: Detailed initial launch prompt, configuration, and shared context snapshot.
|
|
140
150
|
|
|
141
|
-
**Batch Inspection (
|
|
151
|
+
**Batch Inspection (`2-20` IDs, `summary` view only):** Pass 2 to 20 IDs in `runIds` to check multiple background runs in a single bounded call (non-`summary` views require a single target):
|
|
142
152
|
```ts
|
|
143
153
|
await external_runs({
|
|
144
154
|
action: "inspect",
|
|
145
155
|
runIds: [runId1, runId2],
|
|
146
|
-
view: "summary", // Batch inspect is summary-only
|
|
147
156
|
});
|
|
148
157
|
```
|
|
149
158
|
|
|
150
159
|
### 3. `wait`
|
|
151
|
-
Block until one or more targets reach terminal state:
|
|
160
|
+
Block until one or more targets (`1-100` IDs in `runIds`) reach terminal state:
|
|
152
161
|
```ts
|
|
153
162
|
const waitResult = await external_runs({
|
|
154
163
|
action: "wait",
|
|
@@ -162,7 +171,7 @@ const waitResult = await external_runs({
|
|
|
162
171
|
- **Shared Budget:** The `limitBytes` cap is spent across settled results in requested order. If a result exceeds remaining space, it returns `resultTruncated: true` with references to inspect the rest.
|
|
163
172
|
|
|
164
173
|
### 4. `cancel`
|
|
165
|
-
Cancel
|
|
174
|
+
Cancel a single active run (`1` ID via `runIds: [runId]` or `runId`):
|
|
166
175
|
```ts
|
|
167
176
|
await external_runs({
|
|
168
177
|
action: "cancel",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tranhoangnguyen0310/pi-flow-external",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0-external.0",
|
|
4
4
|
"description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -41,22 +41,22 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc",
|
|
43
43
|
"check": "tsc --noEmit && vitest run",
|
|
44
|
-
"e2e": "node
|
|
44
|
+
"e2e": "node scripts/e2e/external.mjs",
|
|
45
45
|
"field-report": "node scripts/field-report.mjs",
|
|
46
46
|
"test": "vitest run"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@earendil-works/pi-agent-core": "~
|
|
50
|
-
"@earendil-works/pi-ai": "~
|
|
51
|
-
"@earendil-works/pi-coding-agent": "~
|
|
52
|
-
"@earendil-works/pi-tui": "~
|
|
49
|
+
"@earendil-works/pi-agent-core": "~1.3.0",
|
|
50
|
+
"@earendil-works/pi-ai": "~1.3.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "~1.3.0",
|
|
52
|
+
"@earendil-works/pi-tui": "~1.3.0",
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-agent-core": "
|
|
57
|
-
"@earendil-works/pi-ai": "
|
|
58
|
-
"@earendil-works/pi-coding-agent": "
|
|
59
|
-
"@earendil-works/pi-tui": "
|
|
56
|
+
"@earendil-works/pi-agent-core": "1.3.0",
|
|
57
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
59
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
60
60
|
"@types/node": "^24.12.4",
|
|
61
61
|
"typebox": "1.1.38",
|
|
62
62
|
"typescript": "^5.9.3",
|
package/src/config-modal.ts
CHANGED
|
@@ -23,7 +23,13 @@ export class ConfigModal implements Component {
|
|
|
23
23
|
private remember = false;
|
|
24
24
|
private cancelBusy?: () => void;
|
|
25
25
|
focused = true;
|
|
26
|
-
|
|
26
|
+
private tui: TUI;
|
|
27
|
+
private theme: Theme;
|
|
28
|
+
private cancelled: (data:string)=>boolean;
|
|
29
|
+
private externalEditorKey: (data:string)=>boolean;
|
|
30
|
+
constructor(tui: TUI, theme: Theme, cancelled: (data:string)=>boolean = data=>matchesKey(data,Key.escape), externalEditorKey:(data:string)=>boolean = ()=>false) {
|
|
31
|
+
this.tui = tui; this.theme = theme; this.cancelled = cancelled; this.externalEditorKey = externalEditorKey;
|
|
32
|
+
}
|
|
27
33
|
private listTheme() {
|
|
28
34
|
return { selectedPrefix: (s:string)=>this.theme.fg('accent',s), selectedText: (s:string)=>this.theme.fg('accent',s), description:(s:string)=>this.theme.fg('muted',s),scrollInfo:(s:string)=>this.theme.fg('dim',s),noMatch:(s:string)=>this.theme.fg('warning',s) };
|
|
29
35
|
}
|
package/src/contract/runs.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import { failureVariant, strings, successVariant } from "./envelope.ts";
|
|
3
4
|
import { supervisedRunSchema } from "./run.ts";
|
|
@@ -6,7 +7,177 @@ const TOOL = "external_runs";
|
|
|
6
7
|
const runs = Type.Array(supervisedRunSchema);
|
|
7
8
|
const cursor = Type.Optional(Type.String());
|
|
8
9
|
|
|
10
|
+
export const EXTERNAL_RUNS_ACTIONS = ["list", "inspect", "wait", "cancel"] as const;
|
|
9
11
|
export const SINGLE_INSPECT_VIEWS = ["summary", "output", "diagnostics", "final", "launch"] as const;
|
|
12
|
+
export const WAIT_MODES = ["any", "all"] as const;
|
|
13
|
+
export const MAX_WAIT_TARGETS = 100;
|
|
14
|
+
/**
|
|
15
|
+
* Cap for `inspect`'s batch `runIds` mode. Intentionally lower than `wait`'s
|
|
16
|
+
* MAX_WAIT_TARGETS (100): batch inspection returns full projections and may
|
|
17
|
+
* touch live evidence reads per target, so it stays cheap by staying small.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_BATCH_INSPECT_TARGETS = 20;
|
|
20
|
+
|
|
21
|
+
export const externalRunsParameters = Type.Object({
|
|
22
|
+
action: StringEnum(EXTERNAL_RUNS_ACTIONS, {
|
|
23
|
+
description:
|
|
24
|
+
"Required action. list: page session runs and workflow roots (optional: workflowRunId, limit, cursor, workflowCursor; omit runIds, view, mode, limitBytes, reason). inspect: read 1 run with any view or 2-20 runs for batch summary (required: runIds; optional: view, cursor, limitBytes; omit mode, workflowRunId, workflowCursor, limit, reason). wait: block until terminal outcomes for 1-100 runs (required: runIds; optional: mode, limitBytes; omit view, cursor, workflowCursor, limit, workflowRunId, reason). cancel: stop 1 run (required: runIds with 1 ID; optional: reason; omit view, mode, cursor, workflowCursor, limit, limitBytes, workflowRunId).",
|
|
25
|
+
}),
|
|
26
|
+
runIds: Type.Optional(Type.Array(Type.String(), {
|
|
27
|
+
maxItems: MAX_WAIT_TARGETS,
|
|
28
|
+
description:
|
|
29
|
+
"Target run IDs (run_... agent, wf_... workflow). Required for inspect (1 ID for any view: summary, output, diagnostics, final, launch; 2-20 IDs for summary-only batch), wait (1-100 IDs), and cancel (1 ID). Inapplicable to list (omit).",
|
|
30
|
+
})),
|
|
31
|
+
view: Type.Optional(StringEnum(SINGLE_INSPECT_VIEWS, {
|
|
32
|
+
description:
|
|
33
|
+
"Optional for inspect only (default \"summary\"); inapplicable to list, wait, and cancel (omit). Single-target inspect (1 ID) accepts \"summary\" (state/timing/freshness/refs, default), \"output\" (assistant text plus canonical result, partial or final), \"diagnostics\" (tool activity/errors), \"final\" (only the verified canonical terminal answer, empty until a successful terminal boundary exists), or \"launch\" (recorded prompt, context and execution configuration; sensitive, explicit inspection only). Batch inspect (2-20 runIds) only supports \"summary\".",
|
|
34
|
+
})),
|
|
35
|
+
mode: Type.Optional(StringEnum(WAIT_MODES, {
|
|
36
|
+
description:
|
|
37
|
+
"Optional for wait only (\"any\" | \"all\", default \"all\"); inapplicable to list, inspect, and cancel (omit). any returns on the first terminal outcome; all waits for every target. Neither cancels pending work; an unsuccessful selected workflow returns early even in all mode.",
|
|
38
|
+
})),
|
|
39
|
+
cursor: Type.Optional(Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"Optional continuation cursor for list and inspect; inapplicable to wait and cancel (omit). For list: pass back nextCursor verbatim to page the runs stream (all agent runs, or that workflow's children when workflowRunId is set). For single inspect: pass back page.nextCursor with the same runId and view. For batch inspect: pass back nextCursor with the identical runIds array. Stale/reused cursors fail with an actionable error.",
|
|
42
|
+
})),
|
|
43
|
+
workflowCursor: Type.Optional(Type.String({
|
|
44
|
+
description:
|
|
45
|
+
"Optional continuation cursor for unfiltered list only; inapplicable when workflowRunId filters to one workflow's children and for inspect, wait, and cancel (omit). Pass back nextWorkflowCursor verbatim to page the workflow-roots listing (workflows); passing workflowCursor without cursor pages workflows only.",
|
|
46
|
+
})),
|
|
47
|
+
limit: Type.Optional(Type.Integer({
|
|
48
|
+
minimum: 1,
|
|
49
|
+
maximum: 100,
|
|
50
|
+
description: "Optional for list only (1-100); inapplicable to inspect, wait, and cancel (omit). Max entries per list/children page.",
|
|
51
|
+
})),
|
|
52
|
+
limitBytes: Type.Optional(Type.Integer({
|
|
53
|
+
minimum: 4,
|
|
54
|
+
maximum: 65536,
|
|
55
|
+
description:
|
|
56
|
+
"Optional for inspect and wait (4-65536); inapplicable to list and cancel (omit). For inspect: max UTF-8 bytes per page, including the summary view; follow nextCursor for the remainder. For wait: total result budget shared across every settled outcome in the response in request order (default 32768); a result that fits is returned complete, one that does not is truncated with resultTruncated:true and outputRef/diagnosticsRef for the rest.",
|
|
57
|
+
})),
|
|
58
|
+
workflowRunId: Type.Optional(Type.String({
|
|
59
|
+
description:
|
|
60
|
+
"Optional for list only (wf_... ID); inapplicable to inspect, wait, and cancel (omit). Filters list.runs to children of this wf_... workflow only, leaves list.workflows empty, and pages via cursor (nextCursor) rather than workflowCursor.",
|
|
61
|
+
})),
|
|
62
|
+
reason: Type.Optional(Type.String({
|
|
63
|
+
maxLength: 512,
|
|
64
|
+
description: "Optional for cancel only (max 512 chars); inapplicable to list, inspect, and wait (omit). Cancellation reason recorded with the run evidence.",
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type ExternalRunsParameterKey = keyof typeof externalRunsParameters.properties;
|
|
69
|
+
|
|
70
|
+
export const EXTERNAL_RUNS_PARAMETER_KEYS = Object.keys(
|
|
71
|
+
externalRunsParameters.properties,
|
|
72
|
+
) as readonly ExternalRunsParameterKey[];
|
|
73
|
+
|
|
74
|
+
export const EXTERNAL_RUNS_EXAMPLES = {
|
|
75
|
+
list: { action: "list" as const },
|
|
76
|
+
listWorkflowChildren: { action: "list" as const, workflowRunId: "wf_example" },
|
|
77
|
+
listRunsNextPage: { action: "list" as const, cursor: "cursor_from_nextCursor" },
|
|
78
|
+
listWorkflowsNextPage: { action: "list" as const, workflowCursor: "cursor_from_nextWorkflowCursor" },
|
|
79
|
+
inspectSingle: { action: "inspect" as const, runIds: ["run_example"], view: "final" as const },
|
|
80
|
+
inspectBatch: { action: "inspect" as const, runIds: ["run_example_1", "run_example_2"] },
|
|
81
|
+
wait: { action: "wait" as const, runIds: ["run_example"], mode: "all" as const },
|
|
82
|
+
cancel: { action: "cancel" as const, runIds: ["run_example"] },
|
|
83
|
+
cancelWithReason: { action: "cancel" as const, runIds: ["run_example"], reason: "No longer needed" },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type ExternalRunsExample = (typeof EXTERNAL_RUNS_EXAMPLES)[keyof typeof EXTERNAL_RUNS_EXAMPLES];
|
|
87
|
+
|
|
88
|
+
export interface ExternalRunsActionSpec {
|
|
89
|
+
id: "list" | "list_children" | "inspect_single" | "inspect_batch" | "wait" | "cancel";
|
|
90
|
+
label: string;
|
|
91
|
+
action: (typeof EXTERNAL_RUNS_ACTIONS)[number];
|
|
92
|
+
required: readonly ExternalRunsParameterKey[];
|
|
93
|
+
optional: readonly ExternalRunsParameterKey[];
|
|
94
|
+
inapplicable: readonly ExternalRunsParameterKey[];
|
|
95
|
+
rule: string;
|
|
96
|
+
example: ExternalRunsExample;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function defineActionSpec(spec: Omit<ExternalRunsActionSpec, "inapplicable">): ExternalRunsActionSpec {
|
|
100
|
+
const allowed = new Set<ExternalRunsParameterKey>([...spec.required, ...spec.optional]);
|
|
101
|
+
return {
|
|
102
|
+
...spec,
|
|
103
|
+
inapplicable: EXTERNAL_RUNS_PARAMETER_KEYS.filter((key) => !allowed.has(key)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const EXTERNAL_RUNS_ACTION_SPECS: readonly ExternalRunsActionSpec[] = [
|
|
108
|
+
defineActionSpec({
|
|
109
|
+
id: "list",
|
|
110
|
+
label: "list (session runs + workflow roots)",
|
|
111
|
+
action: "list",
|
|
112
|
+
required: ["action"],
|
|
113
|
+
optional: ["limit", "cursor", "workflowCursor", "workflowRunId"],
|
|
114
|
+
rule: "Two independent cursor streams: cursor (from nextCursor) pages runs; workflowCursor (from nextWorkflowCursor) pages workflows. Passing workflowCursor without cursor pages workflows alone.",
|
|
115
|
+
example: EXTERNAL_RUNS_EXAMPLES.list,
|
|
116
|
+
}),
|
|
117
|
+
defineActionSpec({
|
|
118
|
+
id: "list_children",
|
|
119
|
+
label: "list (workflow children via workflowRunId)",
|
|
120
|
+
action: "list",
|
|
121
|
+
required: ["action", "workflowRunId"],
|
|
122
|
+
optional: ["limit", "cursor"],
|
|
123
|
+
rule: "workflowRunId (wf_...) filters runs to that workflow's children and returns workflows: []; page with cursor (nextCursor) only, never workflowCursor.",
|
|
124
|
+
example: EXTERNAL_RUNS_EXAMPLES.listWorkflowChildren,
|
|
125
|
+
}),
|
|
126
|
+
defineActionSpec({
|
|
127
|
+
id: "inspect_single",
|
|
128
|
+
label: "inspect (single run, any view)",
|
|
129
|
+
action: "inspect",
|
|
130
|
+
required: ["action", "runIds"],
|
|
131
|
+
optional: ["view", "cursor", "limitBytes"],
|
|
132
|
+
rule: `1 ID in runIds (legacy runId also accepted); view is ${SINGLE_INSPECT_VIEWS.join("|")} (default summary); cursor continues from page.nextCursor with the same runId and view.`,
|
|
133
|
+
example: EXTERNAL_RUNS_EXAMPLES.inspectSingle,
|
|
134
|
+
}),
|
|
135
|
+
defineActionSpec({
|
|
136
|
+
id: "inspect_batch",
|
|
137
|
+
label: `inspect (batch summary, 2-${MAX_BATCH_INSPECT_TARGETS} runs)`,
|
|
138
|
+
action: "inspect",
|
|
139
|
+
required: ["action", "runIds"],
|
|
140
|
+
optional: ["view", "cursor", "limitBytes"],
|
|
141
|
+
rule: `2-${MAX_BATCH_INSPECT_TARGETS} IDs in runIds (1 ID with default/summary view also uses batch shape); view must be omitted or "summary" (output, diagnostics, final, and launch are rejected); cursor continues from nextCursor with the identical runIds array.`,
|
|
142
|
+
example: EXTERNAL_RUNS_EXAMPLES.inspectBatch,
|
|
143
|
+
}),
|
|
144
|
+
defineActionSpec({
|
|
145
|
+
id: "wait",
|
|
146
|
+
label: `wait (1-${MAX_WAIT_TARGETS} runs)`,
|
|
147
|
+
action: "wait",
|
|
148
|
+
required: ["action", "runIds"],
|
|
149
|
+
optional: ["mode", "limitBytes"],
|
|
150
|
+
rule: `1-${MAX_WAIT_TARGETS} IDs in runIds; mode is ${WAIT_MODES.join("|")} (default all); limitBytes is the shared result budget spent in request order.`,
|
|
151
|
+
example: EXTERNAL_RUNS_EXAMPLES.wait,
|
|
152
|
+
}),
|
|
153
|
+
defineActionSpec({
|
|
154
|
+
id: "cancel",
|
|
155
|
+
label: "cancel (1 run)",
|
|
156
|
+
action: "cancel",
|
|
157
|
+
required: ["action", "runIds"],
|
|
158
|
+
optional: ["reason"],
|
|
159
|
+
rule: "1 ID in runIds (legacy runId also accepted); reason is optional (<=512 chars).",
|
|
160
|
+
example: EXTERNAL_RUNS_EXAMPLES.cancelWithReason,
|
|
161
|
+
}),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compact per-action argument matrix and minimal valid examples generated from
|
|
166
|
+
* `externalRunsParameters` and `EXTERNAL_RUNS_ACTION_SPECS` so on-demand help
|
|
167
|
+
* cannot drift from the executable tool contract.
|
|
168
|
+
*/
|
|
169
|
+
export function formatExternalRunsActionGuidance(): string {
|
|
170
|
+
const rows = EXTERNAL_RUNS_ACTION_SPECS.map((spec) => [
|
|
171
|
+
`- ${spec.label}: required: ${spec.required.join(", ")} | optional: ${spec.optional.join(", ") || "none"} | omit: ${spec.inapplicable.join(", ") || "none"}`,
|
|
172
|
+
` Rule: ${spec.rule}`,
|
|
173
|
+
` Minimal call: ${JSON.stringify(spec.example)}`,
|
|
174
|
+
].join("\n"));
|
|
175
|
+
return [
|
|
176
|
+
"external_runs action-specific arguments (supply only the relevant fields; omit inapplicable ones):",
|
|
177
|
+
...rows,
|
|
178
|
+
`List pagination examples: runs stream -> ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}; workflow roots stream -> ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}.`,
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
10
181
|
|
|
11
182
|
/** One success variant per action (inspect has batch and single shapes); every expected failure has `data: null`. */
|
|
12
183
|
export const externalRunsOutputSchema = Type.Union([
|
|
@@ -28,7 +199,8 @@ export const externalRunsOutputSchema = Type.Union([
|
|
|
28
199
|
outputStatus: Type.Optional(strings("preliminary", "final", "interrupted")),
|
|
29
200
|
}),
|
|
30
201
|
])),
|
|
31
|
-
successVariant(TOOL, Type.Literal("wait"), Type.Object({ mode: strings(
|
|
202
|
+
successVariant(TOOL, Type.Literal("wait"), Type.Object({ mode: strings(...WAIT_MODES), completed: runs, pending: Type.Array(Type.String()) })),
|
|
32
203
|
successVariant(TOOL, Type.Literal("cancel"), Type.Object({ runId: Type.String(), status: strings("requested", "terminal") })),
|
|
33
|
-
failureVariant(TOOL, strings(
|
|
204
|
+
failureVariant(TOOL, strings(...EXTERNAL_RUNS_ACTIONS), Type.Null()),
|
|
34
205
|
]);
|
|
206
|
+
|
package/src/core/concurrency.ts
CHANGED
|
@@ -42,10 +42,13 @@ export class ConcurrencyLimiter {
|
|
|
42
42
|
private active = 0;
|
|
43
43
|
private readonly waiters: Waiter[] = [];
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
readonly max: number;
|
|
46
|
+
|
|
47
|
+
constructor(max: number) {
|
|
46
48
|
if (!Number.isInteger(max) || max < 1) {
|
|
47
49
|
throw new Error("ConcurrencyLimiter max must be a positive integer");
|
|
48
50
|
}
|
|
51
|
+
this.max = max;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
get activeCount(): number {
|
package/src/core/errors.ts
CHANGED
|
@@ -8,8 +8,11 @@ export type FlowErrorCode = (typeof FLOW_ERROR_CODES)[number];
|
|
|
8
8
|
|
|
9
9
|
/** Expected caller/configuration failure, distinct from a programming error. */
|
|
10
10
|
export class ExpectedFlowError extends Error {
|
|
11
|
-
|
|
11
|
+
readonly code: FlowErrorCode;
|
|
12
|
+
|
|
13
|
+
constructor(code: FlowErrorCode, message: string) {
|
|
12
14
|
super(message);
|
|
15
|
+
this.code = code;
|
|
13
16
|
this.name = "ExpectedFlowError";
|
|
14
17
|
}
|
|
15
18
|
}
|
|
@@ -839,7 +839,7 @@ function encodeCursor(cursor: InspectionCursor | ListCursor): string {
|
|
|
839
839
|
}
|
|
840
840
|
|
|
841
841
|
function decodeCursor(value: string): InspectionCursor | ListCursor {
|
|
842
|
-
if (!value || value.length > MAX_CURSOR_CHARS) throw cursorInvalid();
|
|
842
|
+
if (typeof value !== "string" || !value || value.length > MAX_CURSOR_CHARS) throw cursorInvalid();
|
|
843
843
|
let parsed: Record<string, unknown> | undefined;
|
|
844
844
|
try {
|
|
845
845
|
parsed = asRecord(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
|
package/src/core/run-registry.ts
CHANGED
|
@@ -80,11 +80,16 @@ export class RunRegistry {
|
|
|
80
80
|
private readonly closedSessions = new Set<string>();
|
|
81
81
|
private readonly backgroundListeners = new Set<(event: BackgroundRunEvent) => void>();
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
private readonly completedLimit: number;
|
|
84
|
+
private readonly shutdownGraceMs: number;
|
|
85
|
+
|
|
86
|
+
constructor(completedLimit = DEFAULT_COMPLETED_LIMIT, shutdownGraceMs = 4_000) {
|
|
84
87
|
if (!Number.isInteger(completedLimit) || completedLimit < 0) {
|
|
85
88
|
throw new Error("completed run limit must be a non-negative integer");
|
|
86
89
|
}
|
|
87
90
|
if (!Number.isInteger(shutdownGraceMs) || shutdownGraceMs < 0) throw new Error("shutdown grace must be a non-negative integer");
|
|
91
|
+
this.completedLimit = completedLimit;
|
|
92
|
+
this.shutdownGraceMs = shutdownGraceMs;
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
sessionVersion(sessionId: string): number {
|
package/src/external-command.ts
CHANGED
|
@@ -1090,8 +1090,11 @@ type ExternalRunsResult = { content: Array<{ type: string; text?: string }>; det
|
|
|
1090
1090
|
|
|
1091
1091
|
/** A returned external_runs failure, raised with its code so callers recover by code, never by message. */
|
|
1092
1092
|
class RunsActionError extends Error {
|
|
1093
|
-
|
|
1093
|
+
readonly code: string;
|
|
1094
|
+
|
|
1095
|
+
constructor(code: string, message: string) {
|
|
1094
1096
|
super(message);
|
|
1097
|
+
this.code = code;
|
|
1095
1098
|
}
|
|
1096
1099
|
}
|
|
1097
1100
|
|
package/src/external-help.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Type, type Static } from "typebox";
|
|
9
9
|
import { filterProfilesForModelRegistry } from "./core/model.ts";
|
|
10
|
+
import { formatExternalRunsActionGuidance } from "./contract/runs.ts";
|
|
10
11
|
import { disabledHarnessMessage, loadExternalCatalog } from "./profiles.ts";
|
|
11
12
|
import { loadHarnessConfigs } from "./harnesses.ts";
|
|
12
13
|
import {
|
|
@@ -103,6 +104,8 @@ Execution mode: foreground is blocking by default when you need the result next,
|
|
|
103
104
|
|
|
104
105
|
Supervision: external_runs selects targets with runIds. list takes optional workflowRunId, cursor, workflowCursor, and limit. inspect with one id reads summary/output/diagnostics/final/launch and pages with cursor and limitBytes; several ids (up to 20) batch summary only. wait takes up to 100 runIds in mode any or all; check ok for the operation, then each completed run's outcome and pending IDs. It never cancels pending work and can return early on a failed workflow even in all mode. If settlement is already known, skip wait. Use inline successful output when available; otherwise inspect final and follow nextCursor. Failures need diagnostics/output, not final. Follow nextWorkflowCursor for workflow listings too. Foreground-call interruption cancels that call; interrupting wait only stops waiting. Use cancel with one id and an optional reason to stop background work. Cancelling a workflow stops active children; cancelling one child is a catchable workflow error. There is no live steering.
|
|
105
106
|
|
|
107
|
+
${formatExternalRunsActionGuidance()}
|
|
108
|
+
|
|
106
109
|
Replay: resumeFromRunId works only with persisted scriptPath and starts an explicit new attempt. It reuses the longest unchanged prefix of successful child calls; the first changed/failed/cancelled/timed-out call and its suffix run again. Script recomposition is cheap, but child reruns can cost money or repeat side effects. There is no automatic repaired-script replay.
|
|
107
110
|
|
|
108
111
|
Background is an external_workflow tool call parameter ({ script, background: true }), never a meta field — meta.background is silently ignored and does not run the workflow in the background.
|
package/src/external-runs.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
1
|
import { type ExtensionContext, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
4
|
-
import {
|
|
3
|
+
import { type Static } from "typebox";
|
|
5
4
|
import { createHash } from "node:crypto";
|
|
6
5
|
import { resolve } from "node:path";
|
|
7
6
|
import { getRunRecord, inspectRun, listRunRecords, type RunInspectionView, type RunRecordListItem } from "./core/run-inspection.ts";
|
|
@@ -13,7 +12,16 @@ import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
|
|
|
13
12
|
import { EXTERNAL_RUNS_PROMPT_SNIPPET } from "./prompts.ts";
|
|
14
13
|
import { envelope, INLINE_RESULT_BYTES, redactedText, withContract } from "./contract/envelope.ts";
|
|
15
14
|
import { durableAgentRun, journalWorkflowRun, liveAgentRun, liveWorkflowRun, unreadableWorkflowRun, type EvidenceIntegrity, type PublicRun } from "./contract/run.ts";
|
|
16
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
EXTERNAL_RUNS_ACTIONS,
|
|
17
|
+
EXTERNAL_RUNS_EXAMPLES,
|
|
18
|
+
externalRunsOutputSchema,
|
|
19
|
+
externalRunsParameters,
|
|
20
|
+
MAX_BATCH_INSPECT_TARGETS,
|
|
21
|
+
MAX_WAIT_TARGETS,
|
|
22
|
+
SINGLE_INSPECT_VIEWS,
|
|
23
|
+
WAIT_MODES,
|
|
24
|
+
} from "./contract/runs.ts";
|
|
17
25
|
import { ExpectedFlowError } from "./core/errors.ts";
|
|
18
26
|
import { redactSecrets } from "./core/run-record.ts";
|
|
19
27
|
import type { WorkflowToolDetails } from "./types.ts";
|
|
@@ -21,52 +29,6 @@ import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, Workf
|
|
|
21
29
|
|
|
22
30
|
const RUN_ID = /^(?:run|wf)_[A-Za-z0-9_-]{1,128}$/;
|
|
23
31
|
const WORKFLOW_ID = /^wf_[A-Za-z0-9_-]{1,128}$/;
|
|
24
|
-
const MAX_TARGETS = 100;
|
|
25
|
-
/**
|
|
26
|
-
* Cap for `inspect`'s batch `runIds` mode. Intentionally lower than `wait`'s
|
|
27
|
-
* MAX_TARGETS (100): batch inspection returns full projections and may touch
|
|
28
|
-
* live evidence reads per target, so it stays cheap by staying small.
|
|
29
|
-
*/
|
|
30
|
-
const MAX_BATCH_INSPECT_TARGETS = 20;
|
|
31
|
-
|
|
32
|
-
const externalRunsParameters = Type.Object({
|
|
33
|
-
action: StringEnum(["list", "inspect", "wait", "cancel"] as const, {
|
|
34
|
-
description: "list: page runs/workflows; inspect: read one run; wait: block until selected terminal outcomes; cancel: stop one run.",
|
|
35
|
-
}),
|
|
36
|
-
runIds: Type.Optional(Type.Array(Type.String(), {
|
|
37
|
-
maxItems: MAX_TARGETS,
|
|
38
|
-
description: "Target run IDs (run_... agent, wf_... workflow). For inspect: a single-entry list [\"run_...\"] inspects that run with any view (summary, output, diagnostics, final); multiple entries (1-20 targets) batch summary inspect. For cancel: a single-entry list [\"run_...\"]. For wait: any|all of up to 100 targets.",
|
|
39
|
-
})),
|
|
40
|
-
view: Type.Optional(StringEnum(["summary", "output", "diagnostics", "final", "launch"] as const, {
|
|
41
|
-
description: "inspect view: launch (recorded prompt, context and execution configuration; sensitive, explicit inspection only), summary (state/timing/freshness/refs, default), output (assistant text plus canonical result, partial or final), diagnostics (tool activity/errors), final (only the verified canonical terminal answer, empty until a successful terminal boundary exists). Batch inspect (runIds) only supports summary.",
|
|
42
|
-
})),
|
|
43
|
-
mode: Type.Optional(StringEnum(["any", "all"] as const, {
|
|
44
|
-
description: "wait mode: any returns on the first terminal outcome; all waits for every target. Neither cancels pending work; an unsuccessful selected workflow returns early even in all mode.",
|
|
45
|
-
})),
|
|
46
|
-
cursor: Type.Optional(Type.String({
|
|
47
|
-
description: "Opaque continuation cursor from a prior response; pass back verbatim to page. Stale/reused cursors fail with an actionable error.",
|
|
48
|
-
})),
|
|
49
|
-
workflowCursor: Type.Optional(Type.String({
|
|
50
|
-
description: "Opaque cursor paging the workflow-roots listing (list action); pass back verbatim. Not used when workflowRunId filters to one workflow's children.",
|
|
51
|
-
})),
|
|
52
|
-
limit: Type.Optional(Type.Integer({
|
|
53
|
-
minimum: 1,
|
|
54
|
-
maximum: 100,
|
|
55
|
-
description: "Max entries per list/children page.",
|
|
56
|
-
})),
|
|
57
|
-
limitBytes: Type.Optional(Type.Integer({
|
|
58
|
-
minimum: 4,
|
|
59
|
-
maximum: 65536,
|
|
60
|
-
description: "Max bytes per inspect page, including the summary view; follow nextCursor for the remainder. For wait, this is the total result budget shared across every settled outcome in the response (default 32768): a result that fits is returned complete, one that does not is truncated with resultTruncated:true and outputRef/diagnosticsRef for the rest.",
|
|
61
|
-
})),
|
|
62
|
-
workflowRunId: Type.Optional(Type.String({
|
|
63
|
-
description: "list filter: children of this wf_... workflow only.",
|
|
64
|
-
})),
|
|
65
|
-
reason: Type.Optional(Type.String({
|
|
66
|
-
maxLength: 512,
|
|
67
|
-
description: "Optional cancellation reason recorded with the run evidence.",
|
|
68
|
-
})),
|
|
69
|
-
});
|
|
70
32
|
|
|
71
33
|
export type ExternalRunsParams = Static<typeof externalRunsParameters> & {
|
|
72
34
|
/** Legacy single-target selector; preserved for backwards-compatible programmatic/test callers. */
|
|
@@ -112,8 +74,16 @@ function notLive(message: string): ExpectedFlowError {
|
|
|
112
74
|
return new ExpectedFlowError("run_not_live", message);
|
|
113
75
|
}
|
|
114
76
|
|
|
115
|
-
function assertRunId(runId: unknown): asserts runId is string {
|
|
116
|
-
if (typeof runId
|
|
77
|
+
function assertRunId(runId: unknown, action: "inspect" | "wait" | "cancel" = "inspect"): asserts runId is string {
|
|
78
|
+
if (typeof runId === "string" && RUN_ID.test(runId)) return;
|
|
79
|
+
const example = action === "cancel"
|
|
80
|
+
? EXTERNAL_RUNS_EXAMPLES.cancel
|
|
81
|
+
: action === "wait"
|
|
82
|
+
? EXTERNAL_RUNS_EXAMPLES.wait
|
|
83
|
+
: EXTERNAL_RUNS_EXAMPLES.inspectSingle;
|
|
84
|
+
throw invalid(
|
|
85
|
+
`Invalid run ID for ${action}; pass a run_... or wf_... ID in runIds, e.g. ${JSON.stringify(example)}.`,
|
|
86
|
+
);
|
|
117
87
|
}
|
|
118
88
|
|
|
119
89
|
function assertOwned(entry: Pick<RegisteredRunEntry, "sessionId" | "project">, sessionId: string, project: string): void {
|
|
@@ -293,8 +263,16 @@ function decodeUnpersistedCursor(value: string, scope: string): UnpersistedBookm
|
|
|
293
263
|
if (parsed?.kind !== "unpersisted-list") return undefined;
|
|
294
264
|
const keys = Object.keys(parsed).sort().join(",");
|
|
295
265
|
const valid = parsed.v === 1 && ((keys === "after,kind,scope,v" && typeof parsed.after === "string" && RUN_ID.test(parsed.after)) || (keys === "kind,persisted,scope,v" && parsed.persisted === true));
|
|
296
|
-
if (!valid)
|
|
297
|
-
|
|
266
|
+
if (!valid) {
|
|
267
|
+
throw cursorInvalid(
|
|
268
|
+
`Invalid list cursor; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (parsed.scope !== scope) {
|
|
272
|
+
throw cursorInvalid(
|
|
273
|
+
`Cursor belongs to a different list scope for list; use cursor for runs (or workflowRunId children) and workflowCursor for unfiltered workflows, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
298
276
|
return typeof parsed.after === "string" ? { after: parsed.after } : { persisted: true };
|
|
299
277
|
}
|
|
300
278
|
|
|
@@ -317,7 +295,18 @@ async function pageUnpersistedFirst<L extends { runId: string }, D>(
|
|
|
317
295
|
persisted: (limit: number, cursor: string | undefined) => Promise<{ items: D[]; nextCursor?: string }>,
|
|
318
296
|
): Promise<{ live: L[]; items: D[]; nextCursor?: string }> {
|
|
319
297
|
const bookmark = cursor === undefined ? undefined : decodeUnpersistedCursor(cursor, scope);
|
|
320
|
-
if (cursor !== undefined && bookmark === undefined)
|
|
298
|
+
if (cursor !== undefined && bookmark === undefined) {
|
|
299
|
+
try {
|
|
300
|
+
return { live: [], ...await persisted(pageSize, cursor) };
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (error instanceof ExpectedFlowError && error.code === "cursor_invalid") {
|
|
303
|
+
throw cursorInvalid(
|
|
304
|
+
`${error.message} for list; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
321
310
|
if (bookmark && "persisted" in bookmark) return { live: [], ...await persisted(pageSize, undefined) };
|
|
322
311
|
let start = 0;
|
|
323
312
|
if (bookmark) {
|
|
@@ -465,12 +454,15 @@ function encodeBatchCursor(scope: string, runIds: string[], nextIndex: number):
|
|
|
465
454
|
* invalidate the cursor.
|
|
466
455
|
*/
|
|
467
456
|
function decodeBatchCursor(value: string, scope: string, runIds: string[]): number {
|
|
468
|
-
|
|
457
|
+
const invalidBatch = () => cursorInvalid(
|
|
458
|
+
`Invalid inspect batch cursor; pass nextCursor with the identical runIds array, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
|
|
459
|
+
);
|
|
460
|
+
if (typeof value !== "string" || !value || value.length > 8192) throw invalidBatch();
|
|
469
461
|
let parsed: Record<string, unknown>;
|
|
470
462
|
try {
|
|
471
463
|
parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
472
464
|
} catch {
|
|
473
|
-
throw
|
|
465
|
+
throw invalidBatch();
|
|
474
466
|
}
|
|
475
467
|
if (
|
|
476
468
|
!parsed ||
|
|
@@ -480,14 +472,20 @@ function decodeBatchCursor(value: string, scope: string, runIds: string[]): numb
|
|
|
480
472
|
!Array.isArray(parsed.runIds) ||
|
|
481
473
|
!Number.isSafeInteger(parsed.nextIndex)
|
|
482
474
|
) {
|
|
483
|
-
throw
|
|
475
|
+
throw invalidBatch();
|
|
476
|
+
}
|
|
477
|
+
if (parsed.scope !== scope) {
|
|
478
|
+
throw cursorInvalid(
|
|
479
|
+
`Cursor belongs to a different session or project for inspect; restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
|
|
480
|
+
);
|
|
484
481
|
}
|
|
485
|
-
if (parsed.scope !== scope) throw cursorInvalid("Cursor belongs to a different session or project");
|
|
486
482
|
if (JSON.stringify(parsed.runIds) !== JSON.stringify(runIds)) {
|
|
487
|
-
throw cursorInvalid(
|
|
483
|
+
throw cursorInvalid(
|
|
484
|
+
`Batch cursor does not match the requested run IDs for inspect; repeat the identical runIds array to continue paging, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
|
|
485
|
+
);
|
|
488
486
|
}
|
|
489
487
|
const nextIndex = parsed.nextIndex as number;
|
|
490
|
-
if (nextIndex < 0 || nextIndex > runIds.length) throw
|
|
488
|
+
if (nextIndex < 0 || nextIndex > runIds.length) throw invalidBatch();
|
|
491
489
|
return nextIndex;
|
|
492
490
|
}
|
|
493
491
|
|
|
@@ -504,15 +502,18 @@ function encodeProjectionCursor(runId: string, view: RunInspectionView, offset:
|
|
|
504
502
|
}
|
|
505
503
|
|
|
506
504
|
function decodeProjectionCursor(value: string, runId: string, view: RunInspectionView, text: string): number {
|
|
507
|
-
|
|
505
|
+
const invalidInspect = () => cursorInvalid(
|
|
506
|
+
`Invalid inspect cursor; pass page.nextCursor with the same runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
|
|
507
|
+
);
|
|
508
|
+
if (typeof value !== "string" || !value || value.length > 4096) throw invalidInspect();
|
|
508
509
|
let parsed: Record<string, unknown>;
|
|
509
510
|
try {
|
|
510
511
|
parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
511
512
|
} catch {
|
|
512
|
-
throw
|
|
513
|
+
throw invalidInspect();
|
|
513
514
|
}
|
|
514
515
|
const keys = parsed && typeof parsed === "object" ? Object.keys(parsed).sort().join(",") : "";
|
|
515
|
-
if (keys !== "kind,offset,revision,runId,v,view" || parsed.v !== 1 || parsed.kind !== "projection-inspect" || parsed.runId !== runId || parsed.view !== view || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || typeof parsed.revision !== "string") throw
|
|
516
|
+
if (keys !== "kind,offset,revision,runId,v,view" || parsed.v !== 1 || parsed.kind !== "projection-inspect" || parsed.runId !== runId || parsed.view !== view || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || typeof parsed.revision !== "string") throw invalidInspect();
|
|
516
517
|
if (parsed.revision !== projectionRevision(text)) throw cursorStale();
|
|
517
518
|
return parsed.offset as number;
|
|
518
519
|
}
|
|
@@ -700,7 +701,7 @@ function renderExternalRunsCall(args: Record<string, unknown>, theme: Theme): Te
|
|
|
700
701
|
: String(args.runId ?? "");
|
|
701
702
|
detail = `inspect ${target} · ${typeof args.view === "string" ? args.view : "summary"}`;
|
|
702
703
|
} else {
|
|
703
|
-
detail = `list${typeof args.workflowRunId === "string" ? ` · workflow ${args.workflowRunId}` : ""}`;
|
|
704
|
+
detail = `list${typeof args.workflowRunId === "string" && args.workflowRunId.trim() !== "" ? ` · workflow ${args.workflowRunId.trim()}` : ""}`;
|
|
704
705
|
}
|
|
705
706
|
return new Text(`${theme.bold("External Runs")} ${theme.fg("dim", detail)}`, 0, 0);
|
|
706
707
|
}
|
|
@@ -824,7 +825,9 @@ function normalizeInspectSelectors(params: ExternalRunsParams): ExternalRunsPara
|
|
|
824
825
|
? undefined
|
|
825
826
|
: params.runIds;
|
|
826
827
|
if (runId !== undefined && runIds !== undefined && runIds.length > 0) {
|
|
827
|
-
throw invalid(
|
|
828
|
+
throw invalid(
|
|
829
|
+
`inspect accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
|
|
830
|
+
);
|
|
828
831
|
}
|
|
829
832
|
if (runId === params.runId && runIds === params.runIds) return params;
|
|
830
833
|
return { ...params, runId, runIds };
|
|
@@ -850,9 +853,17 @@ export function createExternalRunsTool(
|
|
|
850
853
|
parameters: externalRunsParameters,
|
|
851
854
|
outputSchema: externalRunsOutputSchema,
|
|
852
855
|
async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx: ExtensionContext) {
|
|
853
|
-
const action = params.action
|
|
856
|
+
const action = (params.action && (EXTERNAL_RUNS_ACTIONS as readonly string[]).includes(params.action)) ? params.action : "list";
|
|
854
857
|
return await withContract({ tool: "external_runs", action, failureData: null }, async () => {
|
|
855
858
|
if (params.action === "inspect") {
|
|
859
|
+
if (
|
|
860
|
+
params.limitBytes !== undefined &&
|
|
861
|
+
(typeof params.limitBytes !== "number" || !Number.isSafeInteger(params.limitBytes) || params.limitBytes < 4 || params.limitBytes > 65536)
|
|
862
|
+
) {
|
|
863
|
+
throw invalid(
|
|
864
|
+
`Invalid limitBytes for inspect (must be an integer from 4 to 65536); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
856
867
|
params = normalizeInspectSelectors(params);
|
|
857
868
|
if (params.runId === undefined && params.runIds !== undefined && params.runIds.length === 1 && params.view !== undefined && params.view !== "summary") {
|
|
858
869
|
params = { ...params, runId: params.runIds[0], runIds: undefined };
|
|
@@ -862,30 +873,76 @@ export function createExternalRunsTool(
|
|
|
862
873
|
const runsDirectory = options.runsDirectory();
|
|
863
874
|
|
|
864
875
|
if (params.action === "list") {
|
|
865
|
-
if (
|
|
876
|
+
if (
|
|
877
|
+
params.limit !== undefined &&
|
|
878
|
+
(typeof params.limit !== "number" || !Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > 100)
|
|
879
|
+
) {
|
|
880
|
+
throw invalid(
|
|
881
|
+
`Invalid limit for list (must be an integer from 1 to 100); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
if (
|
|
885
|
+
params.workflowRunId !== undefined &&
|
|
886
|
+
(typeof params.workflowRunId !== "string" || (params.workflowRunId.trim() !== "" && !WORKFLOW_ID.test(params.workflowRunId.trim())))
|
|
887
|
+
) {
|
|
888
|
+
throw invalid(
|
|
889
|
+
`Invalid workflow run ID for list; pass a wf_... ID (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowChildren)}) or omit workflowRunId (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}).`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const workflowRunId = typeof params.workflowRunId === "string" && params.workflowRunId.trim() !== ""
|
|
893
|
+
? params.workflowRunId.trim()
|
|
894
|
+
: undefined;
|
|
895
|
+
if (
|
|
896
|
+
workflowRunId !== undefined &&
|
|
897
|
+
params.workflowCursor !== undefined &&
|
|
898
|
+
(typeof params.workflowCursor !== "string" || params.workflowCursor.trim() !== "")
|
|
899
|
+
) {
|
|
900
|
+
throw invalid(
|
|
901
|
+
`workflowCursor is inapplicable when filtering list by workflowRunId; page child runs with cursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or omit workflowRunId to page workflow roots (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}).`,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
if (
|
|
905
|
+
workflowRunId === undefined &&
|
|
906
|
+
params.workflowCursor !== undefined &&
|
|
907
|
+
typeof params.workflowCursor !== "string"
|
|
908
|
+
) {
|
|
909
|
+
throw cursorInvalid(
|
|
910
|
+
`Invalid workflow list cursor for list; pass nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
if (
|
|
914
|
+
params.cursor !== undefined &&
|
|
915
|
+
typeof params.cursor !== "string"
|
|
916
|
+
) {
|
|
917
|
+
throw cursorInvalid(
|
|
918
|
+
`Invalid cursor for list; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const workflowCursor = typeof params.workflowCursor === "string" ? params.workflowCursor.trim() || undefined : undefined;
|
|
922
|
+
const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
|
|
866
923
|
const workflowDir = getSessionWorkflowDir(ctx);
|
|
867
924
|
// Live state overlays its persisted row on whichever page that row
|
|
868
925
|
// falls, so a live run is never shown as interrupted or listed twice.
|
|
869
926
|
// Live runs with no persisted row yet are paged first, inside the same
|
|
870
927
|
// limit, before the persisted rows begin.
|
|
871
928
|
const registered = new Map(options.registry.list(sessionId, project).map((entry) => [entry.runId, entry]));
|
|
872
|
-
const runScope = listScope("runs", sessionId, project,
|
|
873
|
-
const page =
|
|
929
|
+
const runScope = listScope("runs", sessionId, project, workflowRunId);
|
|
930
|
+
const page = workflowCursor && !cursor
|
|
874
931
|
? { live: [], items: [] }
|
|
875
|
-
: await pageUnpersistedFirst(runScope,
|
|
876
|
-
[...registered.values()].filter((entry) => entry.kind === "agent" && (
|
|
932
|
+
: await pageUnpersistedFirst(runScope, cursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 50))),
|
|
933
|
+
[...registered.values()].filter((entry) => entry.kind === "agent" && (workflowRunId === undefined || entry.workflowRunId === workflowRunId)),
|
|
877
934
|
async (entry) => {
|
|
878
935
|
const persisted = await getRunRecord(runsDirectory, entry.runId);
|
|
879
936
|
return !persisted || persisted.parentSessionId !== sessionId || persisted.project !== project
|
|
880
|
-
|| (
|
|
937
|
+
|| (workflowRunId !== undefined && persisted.workflowRunId !== workflowRunId);
|
|
881
938
|
},
|
|
882
|
-
(limit,
|
|
883
|
-
const historical =
|
|
939
|
+
(limit, c) => listRunRecords({ runsDirectory, sessionId, project, workflowRunId, limit, cursor: c }));
|
|
940
|
+
const historical = workflowRunId
|
|
884
941
|
? { live: [], items: [] }
|
|
885
|
-
: await pageUnpersistedFirst(listScope("workflows", sessionId, project),
|
|
942
|
+
: await pageUnpersistedFirst(listScope("workflows", sessionId, project), workflowCursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 100))),
|
|
886
943
|
[...registered.values()].filter((entry) => entry.kind === "workflow"),
|
|
887
944
|
async (entry) => !workflowDir || !await hasWorkflowJournal(workflowDir, entry.runId, project),
|
|
888
|
-
async (limit,
|
|
945
|
+
async (limit, c) => workflowDir ? listWorkflowJournals(workflowDir, project, limit, c) : { items: [] });
|
|
889
946
|
const workflowRows: Array<{ legacy: Record<string, unknown>; run: PublicRun }> = [
|
|
890
947
|
...historical.live.map((entry) => ({ legacy: liveSummary(entry), run: liveWorkflowRun(entry).run })),
|
|
891
948
|
...historical.items.map((journal) => {
|
|
@@ -915,14 +972,26 @@ export function createExternalRunsTool(
|
|
|
915
972
|
|
|
916
973
|
if (params.action === "inspect" && params.runIds !== undefined) {
|
|
917
974
|
// normalizeInspectSelectors already guarantees runId is unset here.
|
|
918
|
-
if (params.view !== undefined && params.view !== "summary")
|
|
975
|
+
if (params.view !== undefined && params.view !== "summary") {
|
|
976
|
+
throw invalid(
|
|
977
|
+
`Batch inspect (runIds) only supports view: "summary"; use ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)} for batch summary or ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)} for a single run.`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
919
980
|
const runIds = [...new Set(params.runIds)];
|
|
920
981
|
if (runIds.length === 0 || runIds.length > MAX_BATCH_INSPECT_TARGETS) {
|
|
921
|
-
throw invalid(
|
|
982
|
+
throw invalid(
|
|
983
|
+
`inspect runIds requires 1-${MAX_BATCH_INSPECT_TARGETS} run IDs; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)} or ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
for (const runId of runIds) assertRunId(runId, "inspect");
|
|
987
|
+
if (params.cursor !== undefined && typeof params.cursor !== "string") {
|
|
988
|
+
throw cursorInvalid(
|
|
989
|
+
`Invalid batch cursor; pass nextCursor verbatim with the identical runIds array, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
|
|
990
|
+
);
|
|
922
991
|
}
|
|
923
|
-
|
|
992
|
+
const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
|
|
924
993
|
const scope = batchCursorScope(sessionId, project);
|
|
925
|
-
const startIndex =
|
|
994
|
+
const startIndex = cursor ? decodeBatchCursor(cursor, scope, runIds) : 0;
|
|
926
995
|
// Resolve (and thereby validate ownership of) every requested target
|
|
927
996
|
// before returning any page — an invalid or cross-session/project
|
|
928
997
|
// target anywhere in the set fails the whole request, not just the
|
|
@@ -960,9 +1029,20 @@ export function createExternalRunsTool(
|
|
|
960
1029
|
}
|
|
961
1030
|
|
|
962
1031
|
if (params.action === "inspect") {
|
|
963
|
-
|
|
1032
|
+
if (params.view !== undefined && !SINGLE_INSPECT_VIEWS.includes(params.view as any)) {
|
|
1033
|
+
throw invalid(
|
|
1034
|
+
`Invalid view ${JSON.stringify(params.view)} for inspect; choose ${SINGLE_INSPECT_VIEWS.join(", ")}, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
assertRunId(params.runId, "inspect");
|
|
964
1038
|
const runId = params.runId;
|
|
965
1039
|
const view = params.view ?? "summary";
|
|
1040
|
+
if (params.cursor !== undefined && typeof params.cursor !== "string") {
|
|
1041
|
+
throw cursorInvalid(
|
|
1042
|
+
`Invalid inspect cursor; pass page.nextCursor with the same runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
|
|
966
1046
|
const entry = options.registry.get(runId);
|
|
967
1047
|
if (entry) assertOwned(entry, sessionId, project);
|
|
968
1048
|
const single = (text: string, encoding: "json" | "text", details: Record<string, unknown>, extra: { finalAvailable?: boolean; outputStatus?: string } = {}, contentText = text) => {
|
|
@@ -990,11 +1070,11 @@ export function createExternalRunsTool(
|
|
|
990
1070
|
// layer's job (src/external-command.ts's showPages), not this tool's.
|
|
991
1071
|
if (!finalAvailable) {
|
|
992
1072
|
const status = entry ? (entry.state === "running" ? "running" : entry.outcome?.status) : historical!.status;
|
|
993
|
-
const page = projectionPage(runId, view, "",
|
|
1073
|
+
const page = projectionPage(runId, view, "", cursor, params.limitBytes);
|
|
994
1074
|
return single(page.text, "json", { runId, view, text: page.text, finalAvailable: false, status: status ?? "running", nextCursor: page.nextCursor }, { finalAvailable: false });
|
|
995
1075
|
}
|
|
996
1076
|
const text = JSON.stringify(redactSecrets({ runId, result: workflowResult, finalAvailable: true }));
|
|
997
|
-
const page = projectionPage(runId, view, text,
|
|
1077
|
+
const page = projectionPage(runId, view, text, cursor, params.limitBytes);
|
|
998
1078
|
return single(page.text, "json", { runId, view, text: page.text, finalAvailable: true, nextCursor: page.nextCursor }, { finalAvailable: true });
|
|
999
1079
|
}
|
|
1000
1080
|
const source = view === "launch"
|
|
@@ -1008,7 +1088,7 @@ export function createExternalRunsTool(
|
|
|
1008
1088
|
? { runId, result: entry ? entry.outcome?.result : historical?.result }
|
|
1009
1089
|
: { runId, status: entry?.outcome?.status ?? historical?.status ?? "running", error: entry?.outcome?.error ?? historical?.error };
|
|
1010
1090
|
const text = JSON.stringify(redactSecrets(source));
|
|
1011
|
-
const page = projectionPage(runId, view, text,
|
|
1091
|
+
const page = projectionPage(runId, view, text, cursor, params.limitBytes);
|
|
1012
1092
|
return single(page.text, "json", { runId, view, text: page.text, nextCursor: page.nextCursor });
|
|
1013
1093
|
}
|
|
1014
1094
|
// A wf_... ID that resolved to neither a live nor a historical
|
|
@@ -1020,25 +1100,35 @@ export function createExternalRunsTool(
|
|
|
1020
1100
|
if (!entry) assertOwnedRecord(durable, sessionId, project);
|
|
1021
1101
|
if (view === "summary") {
|
|
1022
1102
|
const source = entry ? redactSecrets(projectLiveAgent(entry)) : historicalAgent(durable!);
|
|
1023
|
-
const page = projectionPage(runId, view, JSON.stringify(source),
|
|
1103
|
+
const page = projectionPage(runId, view, JSON.stringify(source), cursor, params.limitBytes);
|
|
1024
1104
|
return single(page.text, "json", { runId, view, text: page.text, nextCursor: page.nextCursor });
|
|
1025
1105
|
}
|
|
1026
1106
|
// A settled successful result in the registry is the verified final
|
|
1027
1107
|
// answer, so a final reference stays followable even when evidence
|
|
1028
1108
|
// was never persisted.
|
|
1029
|
-
if (view === "final" && entry?.kind === "agent" && entry.outcome?.status === "done" && entry.outcome.result !== undefined && (!
|
|
1030
|
-
const page = projectionPage(runId, view, liveAgentOutput(entry) ?? "",
|
|
1109
|
+
if (view === "final" && entry?.kind === "agent" && entry.outcome?.status === "done" && entry.outcome.result !== undefined && (!cursor || isProjectionCursor(cursor))) {
|
|
1110
|
+
const page = projectionPage(runId, view, liveAgentOutput(entry) ?? "", cursor, params.limitBytes);
|
|
1031
1111
|
return single(page.text, "text", { runId, view, text: page.text, finalAvailable: true, outputStatus: "final", nextCursor: page.nextCursor }, { finalAvailable: true, outputStatus: "final" });
|
|
1032
1112
|
}
|
|
1033
|
-
if (view === "output" && entry?.kind === "agent" && (!
|
|
1113
|
+
if (view === "output" && entry?.kind === "agent" && (!cursor || isProjectionCursor(cursor))) {
|
|
1034
1114
|
const text = liveAgentOutput(entry);
|
|
1035
1115
|
if (text !== undefined) {
|
|
1036
|
-
const page = projectionPage(runId, view, text,
|
|
1116
|
+
const page = projectionPage(runId, view, text, cursor, params.limitBytes);
|
|
1037
1117
|
const outputStatus = entry.state === "running" ? "preliminary" : entry.outcome?.outcome === "succeeded" ? "final" : "interrupted";
|
|
1038
1118
|
return single(page.text, "text", { runId, view, text: page.text, outputStatus, nextCursor: page.nextCursor }, { outputStatus });
|
|
1039
1119
|
}
|
|
1040
1120
|
}
|
|
1041
|
-
|
|
1121
|
+
let page: Awaited<ReturnType<typeof inspectRun>>;
|
|
1122
|
+
try {
|
|
1123
|
+
page = await inspectRun({ runsDirectory, runId, view, limitBytes: params.limitBytes, cursor, live: entry?.state === "running" });
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
if (error instanceof ExpectedFlowError && error.code === "cursor_invalid") {
|
|
1126
|
+
throw cursorInvalid(
|
|
1127
|
+
`${error.message} for inspect; pass nextCursor with matching runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1042
1132
|
const text = pageItemsText(page);
|
|
1043
1133
|
// view: "final" stays a clean, narration-free canonical-answer
|
|
1044
1134
|
// surface — a bounded empty page (finalAvailable:false) when
|
|
@@ -1050,16 +1140,25 @@ export function createExternalRunsTool(
|
|
|
1050
1140
|
}
|
|
1051
1141
|
|
|
1052
1142
|
if (params.action === "cancel") {
|
|
1143
|
+
if (params.reason !== undefined && (typeof params.reason !== "string" || params.reason.length > 512)) {
|
|
1144
|
+
throw invalid(
|
|
1145
|
+
`Invalid reason for cancel (must be a string up to 512 characters); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancelWithReason)}.`,
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1053
1148
|
const rawRunIds = params.runIds;
|
|
1054
1149
|
if (rawRunIds?.length && params.runId?.trim()) {
|
|
1055
|
-
throw invalid(
|
|
1150
|
+
throw invalid(
|
|
1151
|
+
`cancel accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancel)}.`,
|
|
1152
|
+
);
|
|
1056
1153
|
}
|
|
1057
1154
|
if (rawRunIds && rawRunIds.length > 1) {
|
|
1058
|
-
throw invalid(
|
|
1155
|
+
throw invalid(
|
|
1156
|
+
`cancel targets one run at a time; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancel)}.`,
|
|
1157
|
+
);
|
|
1059
1158
|
}
|
|
1060
1159
|
const targetRunId = (rawRunIds && rawRunIds.length === 1 ? rawRunIds[0] : undefined)
|
|
1061
1160
|
?? (typeof params.runId === "string" && params.runId.trim() !== "" ? params.runId.trim() : undefined);
|
|
1062
|
-
assertRunId(targetRunId);
|
|
1161
|
+
assertRunId(targetRunId, "cancel");
|
|
1063
1162
|
const cancelled = (status: "requested" | "terminal") => ({
|
|
1064
1163
|
...result(status === "requested" ? `Cancellation requested for ${targetRunId}.` : `${targetRunId} is already terminal.`, { runId: targetRunId, status }),
|
|
1065
1164
|
data: { runId: targetRunId, status },
|
|
@@ -1067,7 +1166,8 @@ export function createExternalRunsTool(
|
|
|
1067
1166
|
const entry = options.registry.get(targetRunId);
|
|
1068
1167
|
if (entry) {
|
|
1069
1168
|
assertOwned(entry, sessionId, project);
|
|
1070
|
-
const
|
|
1169
|
+
const reason = params.reason?.trim() ? params.reason.trim() : "cancelled by external_runs";
|
|
1170
|
+
const status = options.registry.cancel(targetRunId, reason);
|
|
1071
1171
|
// "unknown" means the entry left the registry between get and cancel.
|
|
1072
1172
|
if (status === "unknown") throw unavailable();
|
|
1073
1173
|
return cancelled(status);
|
|
@@ -1086,9 +1186,36 @@ export function createExternalRunsTool(
|
|
|
1086
1186
|
throw notLive("Run is no longer live in this session; cancellation cannot be confirmed");
|
|
1087
1187
|
}
|
|
1088
1188
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1189
|
+
if (params.action === "wait") {
|
|
1190
|
+
if (
|
|
1191
|
+
params.limitBytes !== undefined &&
|
|
1192
|
+
(typeof params.limitBytes !== "number" || !Number.isSafeInteger(params.limitBytes) || params.limitBytes < 4 || params.limitBytes > 65536)
|
|
1193
|
+
) {
|
|
1194
|
+
throw invalid(
|
|
1195
|
+
`Invalid limitBytes for wait (must be an integer from 4 to 65536); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
if (params.mode !== undefined && !WAIT_MODES.includes(params.mode as any)) {
|
|
1199
|
+
throw invalid(
|
|
1200
|
+
`Invalid mode ${JSON.stringify(params.mode)} for wait; choose ${WAIT_MODES.join(" or ")}, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
const rawRunIds = params.runIds;
|
|
1204
|
+
if (rawRunIds?.length && params.runId?.trim()) {
|
|
1205
|
+
throw invalid(
|
|
1206
|
+
`wait accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
const rawWaitRunIds = (rawRunIds && rawRunIds.length > 0)
|
|
1210
|
+
? rawRunIds
|
|
1211
|
+
: (params.runId?.trim() ? [params.runId.trim()] : []);
|
|
1212
|
+
const runIds = [...new Set(rawWaitRunIds)];
|
|
1213
|
+
if (runIds.length === 0 || runIds.length > MAX_WAIT_TARGETS) {
|
|
1214
|
+
throw invalid(
|
|
1215
|
+
`wait requires 1-${MAX_WAIT_TARGETS} run IDs; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
for (const runId of runIds) assertRunId(runId, "wait");
|
|
1092
1219
|
const known: WaitTarget[] = await Promise.all(runIds.map(async (runId): Promise<WaitTarget> => {
|
|
1093
1220
|
const entry = options.registry.get(runId);
|
|
1094
1221
|
if (entry) {
|
|
@@ -1201,6 +1328,13 @@ export function createExternalRunsTool(
|
|
|
1201
1328
|
data: { mode, completed, pending: finalPending },
|
|
1202
1329
|
warnings: [...warnings],
|
|
1203
1330
|
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
throw invalid(
|
|
1334
|
+
params.action === undefined
|
|
1335
|
+
? `Missing required action; choose list, inspect, wait, or cancel, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`
|
|
1336
|
+
: `Invalid action ${JSON.stringify(params.action)}; choose list, inspect, wait, or cancel, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
|
|
1337
|
+
);
|
|
1204
1338
|
});
|
|
1205
1339
|
},
|
|
1206
1340
|
renderCall(args, theme) {
|
package/src/pi-subagent.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolve, join } from "node:path";
|
|
|
17
17
|
import { resolveExecutionProfile } from './execution-config.ts';
|
|
18
18
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
|
+
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
20
21
|
import { Text } from "@earendil-works/pi-tui";
|
|
21
22
|
import { Type, type Static } from "typebox";
|
|
22
23
|
import {
|
|
@@ -51,7 +52,7 @@ import { registerProfileCreator, startRoleInterview, startHarnessInterview } fro
|
|
|
51
52
|
import { registerExternalCommand } from "./external-command.ts";
|
|
52
53
|
import { DEFAULT_EXTERNAL_SETTINGS, loadExternalSettings, resolveCtxDefaultHarness, resolveExternalSettings, renderDefaultHarness } from "./settings.ts";
|
|
53
54
|
import { EXTERNAL_HARNESSES } from "./types.ts";
|
|
54
|
-
import { EXTERNAL_DELEGATE_TOOL } from "./tool-names.ts";
|
|
55
|
+
import { EXTERNAL_DELEGATE_TOOL, EXTERNAL_WORKFLOW_TOOL } from "./tool-names.ts";
|
|
55
56
|
import type {
|
|
56
57
|
PermissionTier,
|
|
57
58
|
SubagentBackend,
|
|
@@ -257,6 +258,45 @@ function isSubagentProgressNode(value: unknown): value is SubagentProgressNode {
|
|
|
257
258
|
);
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
// Launcher names a transcript saved before the #85 rename declares, and their replacements.
|
|
262
|
+
const RENAMED_LAUNCHERS: ReadonlyArray<readonly [legacy: string, current: string]> = [
|
|
263
|
+
["Agent", EXTERNAL_DELEGATE_TOOL],
|
|
264
|
+
["workflow", EXTERNAL_WORKFLOW_TOOL],
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
// Launchers the user kept off on the command line. Pi applies `--tools` modifiers
|
|
268
|
+
// to the restored loadout but does not expose them to extensions, and a `-name`
|
|
269
|
+
// modifier cannot match the legacy name it replaces. `--no-tools` turns
|
|
270
|
+
// extension tools off by default. SDK `tools` options are not visible here.
|
|
271
|
+
function cliExcludedLaunchers(argv: readonly string[]): Set<string> {
|
|
272
|
+
if (argv.includes("--no-tools") || argv.includes("-nt")) return new Set(RENAMED_LAUNCHERS.map(([, current]) => current));
|
|
273
|
+
let tools: string[] = [];
|
|
274
|
+
argv.forEach((arg, index) => {
|
|
275
|
+
if ((arg === "--tools" || arg === "-t") && index + 1 < argv.length) {
|
|
276
|
+
tools = argv[index + 1].split(",").map((name) => name.trim());
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
return new Set(tools.filter((name) => name.startsWith("-")).map((name) => name.slice(1)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Pi restores a resumed or tree-navigated session's tool loadout from its
|
|
283
|
+
// transcript. A legacy name no longer registers, so its replacement stays off.
|
|
284
|
+
// Activate the replacement for each legacy name the restored loadout declares,
|
|
285
|
+
// unless the loadout also declares the replacement (it was then turned off on
|
|
286
|
+
// purpose) or the command line excludes it. setActiveTools ignores a tool that
|
|
287
|
+
// is not registered or not allowed.
|
|
288
|
+
function restoreRenamedLaunchers(pi: ExtensionAPI, ctx: ExtensionContext, argv: readonly string[] = process.argv): void {
|
|
289
|
+
const declared = new Set(
|
|
290
|
+
(getCurrentSystemMessage(ctx.sessionManager.buildSessionProjection().messages)?.toolsAdded ?? []).map((tool) => tool.name),
|
|
291
|
+
);
|
|
292
|
+
const active = pi.getActiveTools();
|
|
293
|
+
const excluded = cliExcludedLaunchers(argv);
|
|
294
|
+
const restored = RENAMED_LAUNCHERS
|
|
295
|
+
.filter(([legacy, current]) => declared.has(legacy) && !declared.has(current) && !active.includes(current) && !excluded.has(current))
|
|
296
|
+
.map(([, current]) => current);
|
|
297
|
+
if (restored.length > 0) pi.setActiveTools([...active, ...restored]);
|
|
298
|
+
}
|
|
299
|
+
|
|
260
300
|
function createUsageStatusState(): SubagentUsageStatusState {
|
|
261
301
|
return {
|
|
262
302
|
calls: new Map(),
|
|
@@ -1019,7 +1059,9 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
|
|
|
1019
1059
|
if (activeSessionId) scheduleWakeFlush(activeSessionId);
|
|
1020
1060
|
});
|
|
1021
1061
|
|
|
1062
|
+
pi.on("session_tree", (_event, ctx) => restoreRenamedLaunchers(pi, ctx));
|
|
1022
1063
|
pi.on("session_start", (_event, ctx) => {
|
|
1064
|
+
restoreRenamedLaunchers(pi, ctx);
|
|
1023
1065
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
1024
1066
|
rememberSessionContext(ctx, sessionId);
|
|
1025
1067
|
inAgentTurn = false;
|
package/src/prompts.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EXTERNAL_RUNS_EXAMPLES, formatExternalRunsActionGuidance } from "./contract/runs.ts";
|
|
1
2
|
import type { SavedWorkflow } from "./workflow/registry.ts";
|
|
2
3
|
import { EXTERNAL_HARNESSES, type PermissionTier, type SubagentProfile } from "./types.ts";
|
|
3
4
|
import { externalProfileRole, externalRoleAvailability } from "./profiles.ts";
|
|
@@ -12,7 +13,7 @@ export const EXTERNAL_HELP_PROMPT_SNIPPET =
|
|
|
12
13
|
"Show the delegation playbook, role details, permission behavior, or workflow guidance on demand.";
|
|
13
14
|
|
|
14
15
|
export const EXTERNAL_RUNS_PROMPT_SNIPPET =
|
|
15
|
-
"
|
|
16
|
+
"Supervise session-owned external runs (supply only fields for the chosen action): list (optional workflowRunId, limit, cursor, workflowCursor), inspect (runIds: 1 ID with any view, or 2-20 IDs for summary batch), wait (runIds 1-100, optional mode/limitBytes), or cancel (runIds: 1 ID, optional reason). view: final returns only a verified terminal answer, empty until one exists.";
|
|
16
17
|
|
|
17
18
|
// Only elide the "(harness only)" suffix when every configured harness
|
|
18
19
|
// carries the role; a role available on all configured CLIs but no pi harness
|
|
@@ -143,12 +144,8 @@ export const USAGE_BACKGROUND_AGENT: UsageAgentExample = {
|
|
|
143
144
|
background: true,
|
|
144
145
|
};
|
|
145
146
|
|
|
146
|
-
/** Single-id
|
|
147
|
-
export const USAGE_RUNS_FINAL =
|
|
148
|
-
action: "inspect" as const,
|
|
149
|
-
runIds: ["run_example"],
|
|
150
|
-
view: "final" as const,
|
|
151
|
-
};
|
|
147
|
+
/** Single-id inspect with final view, shared with the authoritative external_runs contract examples. */
|
|
148
|
+
export const USAGE_RUNS_FINAL = EXTERNAL_RUNS_EXAMPLES.inspectSingle;
|
|
152
149
|
|
|
153
150
|
/** Continues a CLI child that stored a session id. Named pi-* harnesses cannot resume. */
|
|
154
151
|
export const USAGE_RESUME_AGENT: UsageAgentExample = {
|
|
@@ -219,6 +216,8 @@ Choose foreground by default when you need the result next, even for a long or p
|
|
|
219
216
|
|
|
220
217
|
Unless the user deferred collection, after independent work use external_runs wait if settlement is not already known. When the user later asks about a deferred run, inspect its runId first and wait only if it is still running. Check ok for the operation, then each completed run's state.outcome and pending IDs; a failed workflow can return wait early even in mode all. Use successful output when delivery is inline (null/false/0 are valid); otherwise inspect view final, following cursor pages. For failures inspect diagnostics or output; final is empty without verified success. A successful workflow may still have children.failed when its script caught child errors; read the reports. Interrupting the original foreground call cancels its work. Interrupting wait only stops waiting; cancel explicitly to stop background work. Cancelling a workflow stops active children; cancelling one child throws a catchable child error. Both modes share permissions, budget enforcement, and concurrency limits. A failed or aborted run stays failed. Retry it, or switch model or harness, only when the user authorized that step. agy may make one disclosed infrastructure retry; that exception is not yours to extend.
|
|
221
218
|
|
|
219
|
+
${formatExternalRunsActionGuidance()}
|
|
220
|
+
|
|
222
221
|
One external_delegate call. Permission stays omitted; the prompt carries the no-edit intent:
|
|
223
222
|
${jsonBlock(USAGE_ONE_AGENT)}
|
|
224
223
|
|
package/src/workflow/journal.ts
CHANGED
|
@@ -48,8 +48,13 @@ export interface LoadedWorkflowJournal {
|
|
|
48
48
|
|
|
49
49
|
/** A journal that exists but cannot be loaded; `project` is set when its own run_start line names one. */
|
|
50
50
|
export class WorkflowJournalReadError extends ExpectedFlowError {
|
|
51
|
-
|
|
51
|
+
readonly project: string | undefined;
|
|
52
|
+
readonly integrity: "damaged" | "unknown";
|
|
53
|
+
|
|
54
|
+
constructor(message: string, project: string | undefined, integrity: "damaged" | "unknown") {
|
|
52
55
|
super("run_unavailable", message);
|
|
56
|
+
this.project = project;
|
|
57
|
+
this.integrity = integrity;
|
|
53
58
|
this.name = "WorkflowJournalReadError";
|
|
54
59
|
}
|
|
55
60
|
}
|
|
@@ -243,7 +248,7 @@ function workflowListCursor(name: string): string {
|
|
|
243
248
|
|
|
244
249
|
function workflowListStart(names: string[], cursor?: string): number {
|
|
245
250
|
if (!cursor) return 0;
|
|
246
|
-
if (cursor.length > 4_096) throw new ExpectedFlowError("cursor_invalid", "Invalid workflow list cursor");
|
|
251
|
+
if (typeof cursor !== "string" || cursor.length > 4_096) throw new ExpectedFlowError("cursor_invalid", "Invalid workflow list cursor");
|
|
247
252
|
try {
|
|
248
253
|
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
249
254
|
if (Object.keys(parsed).sort().join(",") !== "kind,name,v" || parsed.v !== 1 || parsed.kind !== "workflow-list" || typeof parsed.name !== "string") throw new Error();
|
|
@@ -260,8 +265,8 @@ export async function listWorkflowJournals(dir: string, project: string, limit =
|
|
|
260
265
|
try {
|
|
261
266
|
names = (await readdir(dir)).filter((name) => /^run-wf_[A-Za-z0-9_-]{1,128}\.jsonl$/.test(name)).sort().reverse();
|
|
262
267
|
} catch (error) {
|
|
263
|
-
if (isNotFound(error))
|
|
264
|
-
|
|
268
|
+
if (!isNotFound(error)) throw error;
|
|
269
|
+
names = [];
|
|
265
270
|
}
|
|
266
271
|
const start = workflowListStart(names, cursor);
|
|
267
272
|
const pageLimit = Math.max(1, Math.min(100, limit));
|