@aeondave/pi-persona 1.17.0 → 1.17.3
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/AGENTS.md +79 -29
- package/CHANGELOG.md +264 -0
- package/README.md +27 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +223 -57
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +189 -35
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +16 -11
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +251 -0
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +195 -136
- package/src/engine/child.ts +1 -1
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +11 -6
- package/src/engine/stream.ts +3 -0
- package/src/exocom/install.ts +2 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +336 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +642 -267
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -3
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/persona.ts +7 -3
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +315 -285
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +328 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
- package/src/persona/state.ts +0 -28
|
@@ -1,33 +1,33 @@
|
|
|
1
1
|
/** `delegate` tool registration. */
|
|
2
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { AgentConfig } from "../agents/agent.ts";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
+
import { END_TURN_NOTE } from "../core/brief.ts";
|
|
5
6
|
import { fenceUntrusted } from "../core/fence.ts";
|
|
6
7
|
import { Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
import { Container, Spacer } from "@earendil-works/pi-tui";
|
|
8
8
|
import { configuredModels } from "../extension/engine.ts";
|
|
9
|
-
import { compactInlineText, MAX_COLLAPSED_CARD_COLUMNS, sanitizeTerminalText } from "../ui/presentation.ts";
|
|
9
|
+
import { compactInlineText, compactVisibleLine, MAX_COLLAPSED_CARD_COLUMNS, sanitizeTerminalText } from "../ui/presentation.ts";
|
|
10
10
|
import { compactVisibleText } from "../ui/presentation.ts";
|
|
11
|
+
import { createReportBody } from "../ui/report-body.ts";
|
|
11
12
|
import { resolveModelRef } from "../core/models.ts";
|
|
12
13
|
import { inventedLegNameHint } from "../core/naming.ts";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { expandDetailHint, failureDetails } from "../extension/shared.ts";
|
|
14
|
+
import { toolUsageField } from "../ui/usage.ts";
|
|
15
|
+
import { expandDetailHint, failureDetails, isInline } from "../extension/shared.ts";
|
|
16
16
|
import {
|
|
17
|
-
DelegationLedger, type
|
|
18
|
-
|
|
19
|
-
validateDelegationBrief, validateParallelWriteSets,
|
|
17
|
+
DelegationLedger, type DelegateParams as DelegateCall, keyInputOf, nameFor, normalizeDelegateConcurrency,
|
|
18
|
+
shortModel, shouldRecordDelegationOutcome, specOf, unknownAgentError, unknownContractError,
|
|
19
|
+
validateDelegationBrief, validateParallelWriteSets, coerceDelegateParams,
|
|
20
20
|
} from "./delegate.ts";
|
|
21
21
|
import { Semaphore } from "../orchestration/parallel.ts";
|
|
22
22
|
import type { AgentRunSpec, SteerFn, StrategyEngine } from "../orchestration/sdk.ts";
|
|
23
23
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
24
|
-
import {
|
|
24
|
+
import { sanitizeLabel } from "../extension/shared.ts";
|
|
25
25
|
import type { PersonaController } from "../persona/controller.ts";
|
|
26
26
|
import type { AgentTree } from "../ui/agent-tree.ts";
|
|
27
|
-
import { runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
|
|
27
|
+
import { buildCompletionReport, runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
|
|
28
|
+
import type { BackgroundRuns } from "./background-run.ts";
|
|
28
29
|
import { emptyUsage, type ProgressSnapshot, type ToolEvent } from "../engine/stream.ts";
|
|
29
30
|
import { progressPatch } from "../ui/agent-tree.ts";
|
|
30
|
-
import type { AddNodeInput } from "../ui/agent-tree.ts";
|
|
31
31
|
import type { RunLimits } from "../core/capabilities.ts";
|
|
32
32
|
|
|
33
33
|
interface DelegateLaunchSnapshot {
|
|
@@ -39,12 +39,22 @@ interface DelegateLaunchSnapshot {
|
|
|
39
39
|
|
|
40
40
|
interface DelegateLaunchRow {
|
|
41
41
|
id: string;
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
label: string;
|
|
43
|
+
detailLabel: string;
|
|
44
|
+
agent: string;
|
|
45
|
+
model?: string;
|
|
46
|
+
displayLabel: string;
|
|
44
47
|
clipped: boolean;
|
|
45
48
|
hasAlias: boolean;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
interface CollapsedLaunchLine {
|
|
52
|
+
prefix: string;
|
|
53
|
+
label: string;
|
|
54
|
+
metadata: string;
|
|
55
|
+
suffix: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
const COLLAPSED_LAUNCH_ROWS = 3;
|
|
49
59
|
|
|
50
60
|
/** Prefer session-persisted identity, then recover old launch cards from the live tracker. */
|
|
@@ -98,51 +108,93 @@ function launchRows(
|
|
|
98
108
|
});
|
|
99
109
|
const named = entries.map(({ run, hasAlias }) => ({
|
|
100
110
|
id: compactInlineText(run.id, { maxChars: 64 }) || "run",
|
|
111
|
+
label: sanitizeTerminalText(run.label),
|
|
112
|
+
agent: sanitizeTerminalText(run.agent),
|
|
113
|
+
...(run.model ? { model: sanitizeTerminalText(run.model) } : {}),
|
|
101
114
|
fullName: runDisplayName(run),
|
|
102
115
|
hasAlias,
|
|
103
116
|
}));
|
|
104
117
|
const counts = new Map<string, number>();
|
|
105
118
|
for (const row of named) counts.set(row.fullName, (counts.get(row.fullName) ?? 0) + 1);
|
|
106
119
|
return named.map((row) => {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
const duplicateSuffix = counts.get(row.fullName)! > 1 ? ` [${compactInlineText(row.id, { maxChars: 16 })}]` : "";
|
|
121
|
+
const detailLabel = `${row.label}${duplicateSuffix}`;
|
|
122
|
+
const displayLabel = compactInlineText(detailLabel, { maxChars: 80 });
|
|
123
|
+
const displayMetadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
|
|
110
124
|
return {
|
|
111
125
|
...row,
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
detailLabel,
|
|
127
|
+
displayLabel,
|
|
128
|
+
clipped: displayLabel !== detailLabel || compactInlineText(displayMetadata, { maxChars: 80 }) !== displayMetadata,
|
|
114
129
|
};
|
|
115
130
|
});
|
|
116
131
|
}
|
|
117
132
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
function collapsedLaunchRows(rows: readonly DelegateLaunchRow[], dropped: number | undefined): CollapsedLaunchLine[] {
|
|
134
|
+
if (rows.length === 0) return [{ prefix: "launched ", label: "", metadata: "", suffix: "" }];
|
|
135
|
+
const visibleCount = Math.min(rows.length, COLLAPSED_LAUNCH_ROWS);
|
|
136
|
+
const omitted = rows.length - visibleCount;
|
|
137
|
+
const droppedCount = typeof dropped === "number" && Number.isFinite(dropped) ? Math.max(0, Math.floor(dropped)) : 0;
|
|
138
|
+
const suffixParts = [
|
|
139
|
+
...(omitted > 0 ? [`+${omitted} more`] : []),
|
|
140
|
+
...(droppedCount > 0 ? [`${droppedCount} task${droppedCount === 1 ? "" : "s"} dropped`] : []),
|
|
141
|
+
];
|
|
142
|
+
const hintText = compactInlineText(expandDetailHint(), { maxChars: 32 });
|
|
143
|
+
const rowsNeedHint = rows.slice(0, visibleCount).some((row, index) => {
|
|
144
|
+
const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
|
|
145
|
+
const prefixWidth = index === 0 ? visibleWidth("launched ") : visibleWidth(" ");
|
|
146
|
+
return row.clipped || prefixWidth + visibleWidth(row.displayLabel) + (metadata ? 3 + visibleWidth(metadata) : 0) > MAX_COLLAPSED_CARD_COLUMNS;
|
|
147
|
+
});
|
|
148
|
+
if (omitted > 0 || droppedCount > 0 || rowsNeedHint) suffixParts.push(hintText);
|
|
149
|
+
const suffix = suffixParts.length > 0 ? ` · ${compactInlineText(suffixParts.join(" · "), { maxChars: 56 })}` : "";
|
|
150
|
+
return rows.slice(0, visibleCount).map((row, index) => {
|
|
151
|
+
const prefix = index === 0 ? "launched " : " ";
|
|
152
|
+
const fixedWidth = visibleWidth(prefix) + (index === visibleCount - 1 ? visibleWidth(suffix) : 0);
|
|
153
|
+
const budget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - fixedWidth);
|
|
154
|
+
const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
|
|
155
|
+
const metadataBudget = Math.min(36, visibleWidth(metadata));
|
|
156
|
+
const labelBudget = Math.max(16, budget - (metadata ? metadataBudget + 3 : 0));
|
|
157
|
+
const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
|
|
158
|
+
const separator = metadata && budget - visibleWidth(label) >= 19 ? " · " : "";
|
|
159
|
+
const availableMetadata = budget - visibleWidth(label) - visibleWidth(separator);
|
|
160
|
+
const shownMetadata = separator && availableMetadata >= 16
|
|
161
|
+
? compactInlineText(metadata, { maxChars: availableMetadata })
|
|
162
|
+
: "";
|
|
163
|
+
const lineSuffix = index === visibleCount - 1 ? suffix : "";
|
|
164
|
+
return { prefix, label, metadata: shownMetadata, suffix: lineSuffix };
|
|
165
|
+
});
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
function expandedLaunchDetails(text: string, rows: readonly DelegateLaunchRow[]): string {
|
|
141
169
|
if (rows.length === 0 || !rows.some((row) => row.hasAlias)) return text;
|
|
142
|
-
const diagnostics = rows.map((row) => `- ${row.
|
|
170
|
+
const diagnostics = rows.map((row) => `- ${[row.detailLabel, row.agent, row.model].filter((value): value is string => !!value).join(" · ")} [${row.id}]`).join("\n");
|
|
143
171
|
return `${text}\n\nLaunched workers:\n${diagnostics}`;
|
|
144
172
|
}
|
|
145
173
|
|
|
174
|
+
function matchesCompleteSingleCall(args: unknown, argsComplete: boolean, isPartial: boolean, row: DelegateLaunchRow): boolean {
|
|
175
|
+
if (!argsComplete || isPartial || !row.hasAlias) return false;
|
|
176
|
+
const coerced = coerceDelegateParams(args);
|
|
177
|
+
if (!coerced.ok || coerced.params.tasks || !coerced.params.agent || !coerced.params.task?.trim()) return false;
|
|
178
|
+
const params = coerced.params;
|
|
179
|
+
const agent = params.agent;
|
|
180
|
+
if (typeof agent !== "string") return false;
|
|
181
|
+
const expectedLabel = nameFor({ agent, ...(params.name ? { name: params.name } : {}) }, 0);
|
|
182
|
+
return row.label === expectedLabel
|
|
183
|
+
&& row.agent === agent
|
|
184
|
+
&& (row.model ?? "") === shortModel(params.model);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function singleLaunchReceipt(row: DelegateLaunchRow): string[] {
|
|
188
|
+
const prefix = "launched ";
|
|
189
|
+
const suffix = row.clipped ? ` · ${compactInlineText(expandDetailHint(), { maxChars: 32 })}` : "";
|
|
190
|
+
const labelBudget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth(prefix) - visibleWidth(suffix));
|
|
191
|
+
const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
|
|
192
|
+
const rows = [compactVisibleLine(`${prefix}${label}${suffix}`, MAX_COLLAPSED_CARD_COLUMNS)];
|
|
193
|
+
const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
|
|
194
|
+
if (metadata) rows.push(compactVisibleLine(` ${compactInlineText(metadata, { maxChars: MAX_COLLAPSED_CARD_COLUMNS - 2 })}`, MAX_COLLAPSED_CARD_COLUMNS));
|
|
195
|
+
return rows;
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
export interface DelegateToolDeps {
|
|
147
199
|
get lastCtx(): ExtensionContext | undefined;
|
|
148
200
|
set lastCtx(value: ExtensionContext | undefined);
|
|
@@ -151,21 +203,18 @@ export interface DelegateToolDeps {
|
|
|
151
203
|
contractNames: () => string[];
|
|
152
204
|
buildEngine: (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, opts?: { async?: boolean }) => StrategyEngine;
|
|
153
205
|
agentTree: AgentTree;
|
|
154
|
-
nextRootId: (prefix: string) => string;
|
|
155
206
|
tracker: AsyncRunTracker;
|
|
207
|
+
/** Headless: joins the runs a call launched (see background-run.ts). */
|
|
208
|
+
runs: BackgroundRuns;
|
|
209
|
+
/** The launch pool, shared with the background `council` and `flow` runs (absent ⇒ a private one). */
|
|
210
|
+
launchPool?: Semaphore;
|
|
156
211
|
config: { ledgerV2: boolean };
|
|
157
212
|
RUN_LIMITS: RunLimits;
|
|
158
213
|
publishAgentTool(agentId: string, event: ToolEvent): void;
|
|
159
214
|
stopRegistry: Map<string, () => void>;
|
|
160
215
|
steerRegistry: Map<string, SteerFn>;
|
|
161
|
-
stopRequested: Set<string>;
|
|
162
|
-
ensurePersonaModels(ctx: ExtensionContext, roster: unknown[]): Promise<void>;
|
|
163
|
-
clearStops(prefix: string): void;
|
|
164
|
-
clearSteers(prefix: string): void;
|
|
165
216
|
drainBusBlock(): string;
|
|
166
217
|
startPeek(): void;
|
|
167
|
-
childUsage: ChildUsageLedger;
|
|
168
|
-
publishPersonaCost(): void;
|
|
169
218
|
}
|
|
170
219
|
|
|
171
220
|
export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): void {
|
|
@@ -205,7 +254,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
205
254
|
skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
|
|
206
255
|
role: Type.Optional(RoleSchema),
|
|
207
256
|
model: Type.Optional(
|
|
208
|
-
Type.String({ description: "
|
|
257
|
+
Type.String({ description: "Optional exact provider/id. Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." }),
|
|
209
258
|
),
|
|
210
259
|
tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override for this sub-agent; [] explicitly grants no tools" }), JsonString])),
|
|
211
260
|
isolation: Type.Optional(
|
|
@@ -215,7 +264,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
215
264
|
Type.Boolean({ description: "true = give this sub-agent working MCP tools (runs it on the child engine so pi-mcp-adapter initializes; the default engine leaves MCP tools 'not initialized'). Pass any server session id in the task to share a server-keyed backend's state." }),
|
|
216
265
|
),
|
|
217
266
|
timeoutMs: Type.Optional(
|
|
218
|
-
Type.Number({ description: "Idle
|
|
267
|
+
Type.Number({ description: "Idle window in ms for this worker, reset by any progress. NOT a deadline: a worker that keeps progressing runs past it. A positive value overrides the shared default; zero or negative uses the default." }),
|
|
219
268
|
),
|
|
220
269
|
writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
|
|
221
270
|
outputContract: Type.Optional(Type.String({ description: "Name of an installed output contract (see /doctor; built-in: default). Omit for free-form output — describe the report shape in requiredArtifacts instead." })),
|
|
@@ -229,7 +278,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
229
278
|
name: Type.Optional(Type.String({ description: LeaderNameDescription })),
|
|
230
279
|
skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
|
|
231
280
|
role: Type.Optional(RoleSchema),
|
|
232
|
-
model: Type.Optional(Type.String({ description: "
|
|
281
|
+
model: Type.Optional(Type.String({ description: "Optional exact provider/id (single mode). Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." })),
|
|
233
282
|
tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override (single mode); [] explicitly grants no tools" }), JsonString])),
|
|
234
283
|
isolation: Type.Optional(
|
|
235
284
|
Type.Union([Type.Literal("none"), Type.Literal("worktree")], { description: "worktree = run the single sub-agent in an isolated git worktree" }),
|
|
@@ -238,7 +287,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
238
287
|
Type.Boolean({ description: "true = give the single sub-agent working MCP tools (runs it on the child engine; the default engine leaves MCP tools 'not initialized')" }),
|
|
239
288
|
),
|
|
240
289
|
timeoutMs: Type.Optional(
|
|
241
|
-
Type.Number({ description: "Single-mode idle
|
|
290
|
+
Type.Number({ description: "Single-mode idle window in ms, reset by any progress. NOT a deadline: a worker that keeps progressing runs past it. A positive value overrides the shared default; zero or negative uses the default." }),
|
|
242
291
|
),
|
|
243
292
|
writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
|
|
244
293
|
outputContract: Type.Optional(Type.String({ description: "Name of an installed output contract (see /doctor; built-in: default). Omit for free-form output — describe the report shape in requiredArtifacts instead." })),
|
|
@@ -251,18 +300,6 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
251
300
|
concurrency: Type.Optional(
|
|
252
301
|
Type.Integer({ minimum: 1, description: `Max children to run at once (default ${d.RUN_LIMITS.maxConcurrency}; larger requests are clamped)` }),
|
|
253
302
|
),
|
|
254
|
-
async: Type.Optional(
|
|
255
|
-
Type.Boolean({
|
|
256
|
-
description:
|
|
257
|
-
"Explicitly run in the background (already the DEFAULT in interactive sessions) — returns run ids at once; each result comes back to you automatically as a follow-up. Set false to force blocking. If both async and sync are supplied, async takes precedence.",
|
|
258
|
-
}),
|
|
259
|
-
),
|
|
260
|
-
sync: Type.Optional(
|
|
261
|
-
Type.Boolean({
|
|
262
|
-
description:
|
|
263
|
-
"Block this turn until the sub-agent(s) finish and return their results inline — only when you need them before your very next step. (Headless sessions already default to sync.) Omit async when using this flag; an explicit async value takes precedence.",
|
|
264
|
-
}),
|
|
265
|
-
),
|
|
266
303
|
});
|
|
267
304
|
|
|
268
305
|
// Canonicalise a delegate's requested model names to provider/id; return a clear
|
|
@@ -289,11 +326,12 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
289
326
|
return undefined;
|
|
290
327
|
}
|
|
291
328
|
|
|
292
|
-
// The
|
|
293
|
-
// fan-out respects
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
329
|
+
// The launch pool: EVERY delegate run passes through here, headless ones included, so a 20-task
|
|
330
|
+
// fan-out respects one concurrency ceiling instead of opening 20 model sessions at once. Queued
|
|
331
|
+
// runs show as "running" with no progress yet; stopping a queued run works (the engine settles a
|
|
332
|
+
// pre-aborted signal without a model call). A background `council`/`flow` takes ONE slot of this same
|
|
333
|
+
// pool for its whole run (tools/background-run.ts); its inner legs are gated by the strategy SDK's own semaphore.
|
|
334
|
+
const asyncSlots = d.launchPool ?? new Semaphore(d.RUN_LIMITS.maxConcurrency);
|
|
297
335
|
let asyncNameSequence = 0;
|
|
298
336
|
|
|
299
337
|
// Runtime anti-loop guard: an identical (agent, model, task) delegation that failed
|
|
@@ -317,30 +355,89 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
317
355
|
return effectiveMcp === true || effectiveTools === undefined || effectiveTools.some((tool) => !readOnlyTools.has(tool));
|
|
318
356
|
}
|
|
319
357
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
358
|
+
// Ownership intent belongs to the agent definition, not to the caller. An agent that declares the area
|
|
359
|
+
// it alone writes (`writeSet` in its frontmatter: research → `.research/`) hands that area to each writing leg
|
|
360
|
+
// that names none of its own, so the persona's ownership gate sees a writer with disjoint ownership
|
|
361
|
+
// rather than an unknown one the supervisor would have to guess a fix for. A leg's own non-empty
|
|
362
|
+
// writeSet always wins, and an agent that declares nothing leaves the leg exactly as the caller wrote
|
|
363
|
+
// it. Declarative like the field it fills: the paths annotate and gate, nothing enforces them at runtime.
|
|
364
|
+
function inheritAgentWriteSets(call: DelegateCall): DelegateCall {
|
|
365
|
+
const declared = (agent: string | undefined, own: readonly string[] | undefined): string[] | undefined => {
|
|
366
|
+
if (own?.some((path) => path.trim())) return undefined;
|
|
367
|
+
const set = d.agents.find((candidate) => candidate.name === agent)?.writeSet;
|
|
368
|
+
return set && set.length > 0 ? [...set] : undefined;
|
|
369
|
+
};
|
|
370
|
+
if (call.tasks && call.tasks.length > 0) {
|
|
371
|
+
return {
|
|
372
|
+
...call,
|
|
373
|
+
tasks: call.tasks.map((task) => {
|
|
374
|
+
const inherited = mayMutateWorkspace(task) ? declared(task.agent, task.writeSet) : undefined;
|
|
375
|
+
return inherited ? { ...task, writeSet: inherited } : task;
|
|
376
|
+
}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (!call.agent || !mayMutateWorkspace({ ...call, agent: call.agent })) return call;
|
|
380
|
+
const inherited = declared(call.agent, call.writeSet);
|
|
381
|
+
return inherited ? { ...call, writeSet: inherited } : call;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// An interactive `tasks` fan-out of 2+ legs gets one header row — `delegate · N legs`, detail `k/N settled`
|
|
385
|
+
// — that leaves with its last leg. Settlement only ever updates or removes it, never adds, so a leg that
|
|
386
|
+
// settles after the tree was cleared (/new, shutdown) cannot resurrect it. Legs are counted as they
|
|
387
|
+
// settle (not derived from the tracker), so a leg failing inside launch() cannot close it early.
|
|
388
|
+
const batchRows = new Map<string, { size: number; settled: number }>();
|
|
389
|
+
const batchRowId = (batch: string): string => `delegate:${batch}`;
|
|
390
|
+
function openBatchRow(batch: string, size: number): string {
|
|
391
|
+
batchRows.set(batch, { size, settled: 0 });
|
|
392
|
+
const id = batchRowId(batch);
|
|
393
|
+
d.agentTree.add({ id, label: `delegate · ${size} legs`, kind: "delegate", status: "running", detail: `0/${size} settled` });
|
|
394
|
+
return id;
|
|
395
|
+
}
|
|
396
|
+
d.tracker.onComplete(({ batch }) => {
|
|
397
|
+
if (batch === undefined) return;
|
|
398
|
+
const row = batchRows.get(batch);
|
|
399
|
+
if (!row) return;
|
|
400
|
+
row.settled += 1;
|
|
401
|
+
if (row.settled < row.size) {
|
|
402
|
+
d.agentTree.update(batchRowId(batch), { detail: `${row.settled}/${row.size} settled` });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
batchRows.delete(batch);
|
|
406
|
+
d.agentTree.update(batchRowId(batch), { status: "done" }); // a node removed while "running" is reported as stopped
|
|
407
|
+
d.agentTree.remove(batchRowId(batch));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Launch one agent (tracked) and add its live async node to the tree. `label` is the bare codename
|
|
411
|
+
// (nameFor) — the model is folded in here (and stored on the d.tracker entry) so the tree node and
|
|
412
|
+
// every intercom digest show the SAME composed name. `inline` (headless) means the launching call
|
|
413
|
+
// stays parked until the run settles, so the leg may not block on a supervisor reply it cannot give.
|
|
414
|
+
// `fanOut` is set for a leg of a `tasks` call: its batch id, the per-call concurrency gate, and the
|
|
415
|
+
// header row it hangs under (interactive fan-outs of 2+ legs only).
|
|
416
|
+
function launchAsyncRun(agent: string, task: string, runSpec: AgentRunSpec, label: string, inline: boolean, fanOut?: { batch: string; slots: Semaphore | undefined; groupId: string | undefined }): string {
|
|
324
417
|
const model = shortModel(runSpec.model);
|
|
325
418
|
// The writer classification travels WITH the run (d.tracker metadata), not in a side Set keyed by
|
|
326
419
|
// the returned id: a thunk that throws synchronously settles the run inside launch(), so a
|
|
327
420
|
// registration after launch() returns would re-insert an already-dead run and leak it.
|
|
328
|
-
const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec) }, (onProgress, runId) => {
|
|
421
|
+
const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec), queued: true, ...(fanOut ? { batch: fanOut.batch } : {}) }, (onProgress, runId) => {
|
|
329
422
|
const nodeId = `async:${runId}`;
|
|
330
423
|
// A real, HARD stop for the async run (a steer is only a soft request the child may
|
|
331
424
|
// ignore): aborting this signal makes the engine call the sub-agent's `agent.abort()`.
|
|
332
425
|
const ac = new AbortController();
|
|
333
426
|
d.stopRegistry.set(nodeId, () => ac.abort());
|
|
334
427
|
const execute = () =>
|
|
335
|
-
asyncSlots.with(() =>
|
|
336
|
-
d.
|
|
428
|
+
asyncSlots.with(() => {
|
|
429
|
+
d.tracker.markAdmitted(runId);
|
|
430
|
+
return d.buildEngine(
|
|
337
431
|
undefined,
|
|
338
432
|
(snap) => {
|
|
339
433
|
onProgress(snap);
|
|
340
434
|
if (snap.toolEvent) d.publishAgentTool(nodeId, snap.toolEvent);
|
|
341
|
-
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
d.agentTree.update(nodeId, progressPatch(snap, now));
|
|
437
|
+
// Any leg advancing is the batch advancing: its header must not show a false stall.
|
|
438
|
+
if (fanOut?.groupId) d.agentTree.update(fanOut.groupId, { lastAdvanceAt: now });
|
|
342
439
|
},
|
|
343
|
-
{ async:
|
|
440
|
+
{ async: !inline },
|
|
344
441
|
// STOP via `ac.signal` (hard abort) and STEER via the run-id key (soft redirect) —
|
|
345
442
|
// both work for the supervisor (intercom `stop`/`steer`) and the f9 overlay (`x`/`s`),
|
|
346
443
|
// for ANY persona (these are supervisor→child controls, not child tools).
|
|
@@ -350,30 +447,19 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
350
447
|
// the queue-time seed, so the elapsed reading and stall badge are both honest.
|
|
351
448
|
const now = Date.now();
|
|
352
449
|
d.agentTree.update(nodeId, { detail: "", startedAt: now, lastAdvanceAt: now });
|
|
353
|
-
})
|
|
354
|
-
|
|
355
|
-
);
|
|
450
|
+
});
|
|
451
|
+
}, ac.signal);
|
|
356
452
|
// A per-call semaphore composes with the process-wide ceiling. Acquire it first so a
|
|
357
453
|
// queued member of a serial batch never occupies a global slot while waiting for its
|
|
358
|
-
// predecessor
|
|
359
|
-
return (
|
|
454
|
+
// predecessor, so `concurrency: 1` means one leg at a time.
|
|
455
|
+
return (fanOut?.slots ? fanOut.slots.with(execute, ac.signal) : execute())
|
|
360
456
|
.catch((error: unknown): AgentResult => {
|
|
361
457
|
if (!ac.signal.aborted) throw error;
|
|
362
458
|
return { agent, output: "", usage: emptyUsage(), ok: false, error: "agent aborted", failureKind: "abort" };
|
|
363
459
|
})
|
|
364
460
|
.then((r) => {
|
|
365
461
|
if (shouldRecordDelegationOutcome(r)) {
|
|
366
|
-
ledger.record(
|
|
367
|
-
{
|
|
368
|
-
agent,
|
|
369
|
-
...(runSpec.model ? { model: runSpec.model } : {}),
|
|
370
|
-
task,
|
|
371
|
-
...(runSpec.role ? { role: runSpec.role } : {}),
|
|
372
|
-
...(runSpec.tools !== undefined ? { tools: runSpec.tools } : {}),
|
|
373
|
-
...(runSpec.isolation ? { isolation: runSpec.isolation } : {}),
|
|
374
|
-
},
|
|
375
|
-
r.ok,
|
|
376
|
-
);
|
|
462
|
+
ledger.record(keyInputOf({ ...runSpec, task }), r.ok);
|
|
377
463
|
}
|
|
378
464
|
return r;
|
|
379
465
|
});
|
|
@@ -385,36 +471,58 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
385
471
|
d.agentTree.add({
|
|
386
472
|
id: nodeId,
|
|
387
473
|
label: model ? `${label} · ${model}` : label,
|
|
474
|
+
...(fanOut?.groupId ? { parentId: fanOut.groupId } : {}),
|
|
388
475
|
status: "running",
|
|
389
476
|
kind: "subagent",
|
|
390
477
|
agent,
|
|
391
478
|
...(runSpec.model ? { model: runSpec.model } : {}),
|
|
392
479
|
detail: "queued",
|
|
393
480
|
});
|
|
394
|
-
|
|
481
|
+
// Arm the async-run status/stall monitor (no-op if both cadences are disabled). A headless leg runs
|
|
482
|
+
// inline with nobody to read a status card, exactly as the pre-tracker headless path did.
|
|
483
|
+
if (!inline) d.startPeek();
|
|
395
484
|
return id;
|
|
396
485
|
}
|
|
397
486
|
|
|
487
|
+
// Headless (`pi -p`): nothing drains a follow-up once the process exits, so the call that launched
|
|
488
|
+
// the runs joins them and returns the very report their completion follow-up would have carried.
|
|
489
|
+
async function settleInline(ids: string[], signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, droppedNote = "") {
|
|
490
|
+
const { runs, usage } = await d.runs.settle(ids, signal, (done, total) => {
|
|
491
|
+
onUpdate?.({ content: [{ type: "text", text: `delegate: ${done}/${total} done` }], details: {} });
|
|
492
|
+
});
|
|
493
|
+
const ok = runs.every((run) => run.status === "done");
|
|
494
|
+
const dropped = droppedNote && `
|
|
495
|
+
|
|
496
|
+
[pi-persona]${droppedNote}`;
|
|
497
|
+
return {
|
|
498
|
+
content: [{ type: "text" as const, text: `${buildCompletionReport(runs, fenceUntrusted, { bounded: false })}${dropped}${d.drainBusBlock()}` }],
|
|
499
|
+
details: ok ? {} : failureDetails({}),
|
|
500
|
+
isError: !ok,
|
|
501
|
+
...toolUsageField(usage),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
398
505
|
pi.registerTool({
|
|
399
506
|
name: "delegate",
|
|
400
507
|
label: "Delegate",
|
|
401
508
|
description: [
|
|
402
|
-
"Delegate work to sub-agents
|
|
403
|
-
"Pass agent and task for one worker, or a tasks array for parallel workers.
|
|
404
|
-
"
|
|
405
|
-
"A persona may also require a structured output contract or disjoint writeSet ownership for parallel writers. Incomplete calls are rejected before any worker starts; fill the missing fields across the batch before retrying.",
|
|
406
|
-
"In interactive sessions it runs in the BACKGROUND by default: you get run ids at once and stay free.",
|
|
407
|
-
"Work independently while children run; when only children remain, end your turn and let their automatic completion follow-ups wake you.",
|
|
408
|
-
"Do not monitor, poll, or automatically wait for all children. Use `intercom wait` only when a result is needed before your next step; `sync: true` intentionally blocks instead; headless runs default to sync.",
|
|
509
|
+
"Delegate bounded independent work to supervised sub-agents by default; you own the objective, decisions and result review. Keep quick, trivial or tightly coupled work local when a hand-off would add more overhead than value.",
|
|
510
|
+
"Pass agent and task for one worker, or a tasks array for parallel workers. Required fields follow the active persona's delegation policy (the per-turn delegation brief shows the exact call shape); an incomplete call is rejected before any worker starts, and the error names what is missing.",
|
|
511
|
+
"Interactive sessions ALWAYS run it in the BACKGROUND: you get run ids at once and stay free, and each result wakes you as a completion follow-up (a `tasks` call wakes you ONCE, when its last leg settles). Work independently meanwhile; when only children remain, end your turn. NEVER sleep, poll, or arm a `timer` to wait for a child. Headless sessions (`pi -p`) block for the turn and return results inline.",
|
|
409
512
|
"Steering received during an active tool may be acted on after that tool returns; do not assume arbitrary tool work is interrupted immediately.",
|
|
410
513
|
"No fitting agent? Shape one on the fly: `operator` + `role` (extra system prompt) + `skills`.",
|
|
411
514
|
"Assign each worker's name before launch so it sees the same identity from its first turn; omitted names keep the generic fallback.",
|
|
412
|
-
"A `model` may be a loose name ('sonnet') — it resolves to YOUR provider's id; ambiguous names return",
|
|
413
|
-
"candidates
|
|
515
|
+
"A `model` may be a loose name ('sonnet', 'opus-4.6', 'claude-pro-max-native/opus-4-6') — it resolves to YOUR provider's id; ambiguous names return",
|
|
516
|
+
"candidates. Other options: name, tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
|
|
414
517
|
].join(" "),
|
|
415
518
|
parameters: DelegateParams,
|
|
416
519
|
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
417
520
|
d.lastCtx = ctx;
|
|
521
|
+
// Interactive sessions (TUI and RPC) ALWAYS run in the background: the supervisor stays free
|
|
522
|
+
// and results return as follow-ups (the idle-gated push path). Headless (`pi -p`) launches the
|
|
523
|
+
// SAME runs but joins them before returning — the single turn must carry the result. A runtime
|
|
524
|
+
// constraint, not a model-facing choice, so there is no flag.
|
|
525
|
+
const inline = isInline(ctx);
|
|
418
526
|
const coerced = coerceDelegateParams(rawParams);
|
|
419
527
|
if (!coerced.ok) return { content: [{ type: "text", text: coerced.error }], details: failureDetails({}), isError: true };
|
|
420
528
|
let params = coerced.params;
|
|
@@ -430,6 +538,8 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
430
538
|
? { ...params, outputContract: defaultOutputContract }
|
|
431
539
|
: params;
|
|
432
540
|
}
|
|
541
|
+
// Before classification and every ownership consumer (the gate, the overlap check, the run specs).
|
|
542
|
+
params = inheritAgentWriteSets(params);
|
|
433
543
|
if (policy?.requireBrief) {
|
|
434
544
|
const briefError = validateDelegationBrief(params);
|
|
435
545
|
if (briefError) return { content: [{ type: "text", text: briefError }], details: failureDetails({}), isError: true };
|
|
@@ -462,16 +572,19 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
462
572
|
const reason = effectiveConcurrency > 1
|
|
463
573
|
? "would overlap a material mutation"
|
|
464
574
|
: "is ordered before a material mutation";
|
|
465
|
-
const message = `delegate: fresh verification must run after every material mutation; ${names} ${reason}. Serialize the batch with every writer first and every declared verifier last, or start the verifier in a later call once every writer has SETTLED (its completion follow-up
|
|
575
|
+
const message = `delegate: fresh verification must run after every material mutation; ${names} ${reason}. Serialize the batch with every writer first and every declared verifier last, or start the verifier in a later call once every writer has SETTLED (interactive: end your turn; its completion follow-up wakes you) — a later call while a writer is still running is rejected the same way.`;
|
|
466
576
|
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
467
577
|
}
|
|
468
578
|
}
|
|
469
579
|
if (effectiveConcurrency > 1) {
|
|
470
580
|
if (policy?.requireDisjointWrites) {
|
|
471
581
|
if (writing.length > 1) {
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
const
|
|
582
|
+
const unowned = writing.filter(({ task }) => !task.writeSet?.some((path) => path.trim()));
|
|
583
|
+
if (unowned.length > 0) {
|
|
584
|
+
const missing = unowned.map(({ index, task }) => `tasks[${index}] ("${task.agent}")`);
|
|
585
|
+
// One remedy per leg, naming the parameters: the caller should never have to guess a fix.
|
|
586
|
+
const remedies = unowned.map(({ index, task }) => `tasks[${index}] ("${task.agent}"): add writeSet: ["<files or directories only this leg edits>"], or make it a reader with tools: ["read","grep","find","ls"] (or pick scout).`);
|
|
587
|
+
const message = `delegate: this persona requires disjoint ownership for parallel writers; missing non-empty writeSet on ${missing.join(", ")}. ${remedies.join(" ")} Or serialize the batch with concurrency: 1.`;
|
|
475
588
|
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
476
589
|
}
|
|
477
590
|
}
|
|
@@ -480,7 +593,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
480
593
|
if (writeSetError) return { content: [{ type: "text", text: writeSetError }], details: failureDetails({}), isError: true };
|
|
481
594
|
}
|
|
482
595
|
}
|
|
483
|
-
// The gate above sees ONE call. Interactive delegate is background
|
|
596
|
+
// The gate above sees ONE call. Interactive delegate is ALWAYS background, so the
|
|
484
597
|
// remedy it prescribes — run the verifier in a later call — lands WHILE the mutation is
|
|
485
598
|
// still running unless the same rule holds across calls; the policy would otherwise be
|
|
486
599
|
// defeated by following its own instructions. Guidance is never the enforcement here
|
|
@@ -498,8 +611,10 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
498
611
|
const omittedMutations = liveMutations.length - visibleMutations.length;
|
|
499
612
|
const inFlight = `${visibleMutations.map((run) => `${run.id} (${sanitizeLabel(run.agent)})`).join(", ")}${omittedMutations > 0 ? `, … +${omittedMutations} more` : ""}`;
|
|
500
613
|
const message =
|
|
501
|
-
`delegate: fresh verification must run after every material mutation; ${verifierList} cannot start while ${inFlight} ${liveMutations.length === 1 ? "is" : "are"} still
|
|
502
|
-
|
|
614
|
+
`delegate: fresh verification must run after every material mutation; ${verifierList} cannot start while ${inFlight} ${liveMutations.length === 1 ? "is" : "are"} still running and may mutate the workspace. ` +
|
|
615
|
+
(inline
|
|
616
|
+
? "The writer's own call returns its result; start the verifier in a later call against the resulting state."
|
|
617
|
+
: "End your turn; the writer's completion follow-up wakes you, then start the verifier against the resulting state.");
|
|
503
618
|
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
504
619
|
}
|
|
505
620
|
}
|
|
@@ -525,36 +640,13 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
525
640
|
// identical delegation that already failed twice does not spawn again.
|
|
526
641
|
const requested =
|
|
527
642
|
params.tasks && params.tasks.length > 0
|
|
528
|
-
? params.tasks.map(
|
|
529
|
-
agent: t.agent,
|
|
530
|
-
...(t.model ? { model: t.model } : {}),
|
|
531
|
-
task: t.task,
|
|
532
|
-
...(t.role ? { role: t.role } : {}),
|
|
533
|
-
...(t.tools !== undefined ? { tools: t.tools } : {}),
|
|
534
|
-
...(t.isolation ? { isolation: t.isolation } : {}),
|
|
535
|
-
}))
|
|
643
|
+
? params.tasks.map(keyInputOf)
|
|
536
644
|
: params.agent && params.task
|
|
537
|
-
? [
|
|
538
|
-
{
|
|
539
|
-
agent: params.agent,
|
|
540
|
-
...(params.model ? { model: params.model } : {}),
|
|
541
|
-
task: params.task,
|
|
542
|
-
...(params.role ? { role: params.role } : {}),
|
|
543
|
-
...(params.tools !== undefined ? { tools: params.tools } : {}),
|
|
544
|
-
...(params.isolation ? { isolation: params.isolation } : {}),
|
|
545
|
-
},
|
|
546
|
-
]
|
|
645
|
+
? [keyInputOf({ ...params, agent: params.agent, task: params.task })]
|
|
547
646
|
: [];
|
|
548
647
|
const veto = ledger.vet(requested);
|
|
549
648
|
if (veto) return { content: [{ type: "text", text: veto }], details: failureDetails({}), isError: true };
|
|
550
|
-
|
|
551
|
-
// return as follow-ups (the idle-gated push path). Headless (`pi -p`) defaults to sync —
|
|
552
|
-
// the single turn must carry the result, and nothing drains a follow-up after the
|
|
553
|
-
// process exits. An explicit `async` always wins; `sync: true` opts one call out.
|
|
554
|
-
const wantsAsync = wantsAsyncRun(params, ctx.hasUI === true);
|
|
555
|
-
// Async (single OR parallel): run in the background so YOU stay free to keep
|
|
556
|
-
// working / answer the user — results arrive later as follow-ups; /peek to watch.
|
|
557
|
-
if (wantsAsync && params.tasks && params.tasks.length > 0) {
|
|
649
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
558
650
|
const tasks = params.tasks.slice(0, d.RUN_LIMITS.maxChildren);
|
|
559
651
|
const dropped = params.tasks.length - tasks.length;
|
|
560
652
|
const effectiveConcurrency = normalizeDelegateConcurrency(params.concurrency, d.RUN_LIMITS.maxConcurrency);
|
|
@@ -563,213 +655,151 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
|
|
|
563
655
|
const batchSlots = effectiveConcurrency < tasks.length ? new Semaphore(effectiveConcurrency) : undefined;
|
|
564
656
|
const nameOffset = asyncNameSequence;
|
|
565
657
|
asyncNameSequence += tasks.length;
|
|
658
|
+
// One id per call: the name offset only ever grows, so no two calls share it.
|
|
659
|
+
const batch = `batch-${nameOffset.toString(36)}`;
|
|
660
|
+
const fanOut = { batch, slots: batchSlots, groupId: !inline && tasks.length > 1 ? openBatchRow(batch, tasks.length) : undefined };
|
|
566
661
|
const runs = tasks.map((t, i): DelegateLaunchSnapshot => {
|
|
567
|
-
// Routed through specOf() (not a hand-rolled field list)
|
|
568
|
-
//
|
|
569
|
-
// `timeoutMs` (and any future knob) lands here for free instead of needing a second copy.
|
|
662
|
+
// Routed through specOf() (not a hand-rolled field list): a per-leg knob such as `timeoutMs`
|
|
663
|
+
// lands here for free instead of needing a second copy.
|
|
570
664
|
const spec = specOf(t, nameOffset + i);
|
|
571
665
|
const label = nameFor(t, nameOffset + i);
|
|
572
|
-
const id = launchAsyncRun(t.agent, t.task, spec, label,
|
|
666
|
+
const id = launchAsyncRun(t.agent, t.task, spec, label, inline, fanOut);
|
|
573
667
|
const model = shortModel(spec.model);
|
|
574
668
|
return { id, label, agent: t.agent, ...(model ? { model } : {}) };
|
|
575
669
|
});
|
|
576
670
|
const ids = runs.map((run) => run.id);
|
|
577
671
|
const droppedNote = dropped > 0 ? ` ${dropped} task(s) beyond the max-children limit (${d.RUN_LIMITS.maxChildren}) were dropped.` : "";
|
|
672
|
+
if (inline) return settleInline(ids, signal, onUpdate, droppedNote);
|
|
578
673
|
return {
|
|
579
674
|
content: [
|
|
580
675
|
{
|
|
581
676
|
type: "text",
|
|
582
|
-
text: `Launched ${ids.length}
|
|
677
|
+
text: `Launched ${ids.length} background leg${ids.length === 1 ? "" : "s"} as one batch: ${runs.map((run) => `${run.id} (${runDisplayName(run)})`).join(", ")}; you will be woken ONCE when the batch settles (${END_TURN_NOTE}).${droppedNote}`,
|
|
583
678
|
},
|
|
584
679
|
],
|
|
585
680
|
details: { runIds: ids, runs, ...(dropped > 0 ? { dropped } : {}) },
|
|
586
681
|
isError: false,
|
|
587
682
|
};
|
|
588
683
|
}
|
|
589
|
-
if (
|
|
684
|
+
if (params.agent && params.task) {
|
|
590
685
|
const agent = params.agent;
|
|
591
686
|
const task = params.task;
|
|
592
687
|
const nameIndex = asyncNameSequence++;
|
|
593
688
|
// Use the canonical mapper here too: explicit `none`/`false` and future fields must
|
|
594
|
-
// survive exactly as they do in fan-out
|
|
689
|
+
// survive exactly as they do in a fan-out.
|
|
595
690
|
const single = { ...params, agent, task };
|
|
596
691
|
const runSpec = specOf(single, nameIndex);
|
|
597
692
|
const label = nameFor(single, nameIndex);
|
|
598
|
-
const id = launchAsyncRun(agent, task, runSpec, label);
|
|
693
|
+
const id = launchAsyncRun(agent, task, runSpec, label, inline);
|
|
694
|
+
if (inline) return settleInline([id], signal, onUpdate);
|
|
599
695
|
const model = shortModel(runSpec.model);
|
|
600
696
|
return {
|
|
601
697
|
content: [
|
|
602
698
|
{
|
|
603
699
|
type: "text",
|
|
604
|
-
text: `Launched async run ${id} (${agent}) — runs in the background
|
|
700
|
+
text: `Launched async run ${id} (${agent}) — runs in the background and wakes you on completion (${END_TURN_NOTE}).`,
|
|
605
701
|
},
|
|
606
702
|
],
|
|
607
703
|
details: { runId: id, runs: [{ id, label, agent, ...(model ? { model } : {}) }] },
|
|
608
704
|
isError: false,
|
|
609
705
|
};
|
|
610
706
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
params,
|
|
617
|
-
d.buildEngine(signal),
|
|
618
|
-
delegateLimits,
|
|
619
|
-
(views) => {
|
|
620
|
-
views.forEach((v, i) => {
|
|
621
|
-
const id = `${delRoot}/${i}`;
|
|
622
|
-
if (!v.running) {
|
|
623
|
-
d.stopRegistry.delete(id);
|
|
624
|
-
d.stopRequested.delete(id);
|
|
625
|
-
d.steerRegistry.delete(id);
|
|
626
|
-
}
|
|
627
|
-
const status = agentNodeStatusForDelegate(v);
|
|
628
|
-
const spec = requested[i];
|
|
629
|
-
const node: AddNodeInput = {
|
|
630
|
-
id,
|
|
631
|
-
label: v.label,
|
|
632
|
-
parentId: delRoot,
|
|
633
|
-
status,
|
|
634
|
-
kind: "subagent",
|
|
635
|
-
...(spec?.agent ? { agent: spec.agent } : {}),
|
|
636
|
-
...(spec?.model ? { model: spec.model } : {}),
|
|
637
|
-
};
|
|
638
|
-
node.detail = v.running ? v.activity : formatUsage(v.usage);
|
|
639
|
-
if (v.output) node.output = v.output;
|
|
640
|
-
d.agentTree.add(node);
|
|
641
|
-
});
|
|
642
|
-
const done = views.filter((v) => !v.running).length;
|
|
643
|
-
onUpdate?.({ content: [{ type: "text", text: `delegate: ${done}/${views.length} done` }], details: { views } });
|
|
644
|
-
},
|
|
645
|
-
(i, abort) => d.stopRegistry.set(`${delRoot}/${i}`, abort),
|
|
646
|
-
(i, steer) => d.steerRegistry.set(`${delRoot}/${i}`, steer),
|
|
647
|
-
// The same run signal the engine was built with: a leg whose engine REJECTS under a
|
|
648
|
-
// whole-run stop must file as "abort", not as an agent failure the user never caused.
|
|
649
|
-
signal,
|
|
650
|
-
(i, event) => d.publishAgentTool(`${delRoot}/${i}`, event),
|
|
651
|
-
);
|
|
652
|
-
// Feed the anti-loop ledger (results align with the requested tasks by index).
|
|
653
|
-
outcome.results.forEach((r, i) => {
|
|
654
|
-
const t = requested[i];
|
|
655
|
-
if (t && shouldRecordDelegationOutcome(r)) ledger.record(t, r.ok);
|
|
656
|
-
});
|
|
657
|
-
d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
|
|
658
|
-
const usage = sumUsage(outcome.results.map((r) => r.usage));
|
|
659
|
-
d.childUsage.account(usage);
|
|
660
|
-
d.publishPersonaCost();
|
|
661
|
-
return {
|
|
662
|
-
// Sub-agent text is untrusted even as a tool result (guardrails §: fence
|
|
663
|
-
// before it reaches the supervisor) — the async path already fences via
|
|
664
|
-
// buildCompletionReport; the sync path must match.
|
|
665
|
-
content: [{ type: "text", text: `${fenceUntrusted(outcome.text)}${d.drainBusBlock()}` }],
|
|
666
|
-
details: outcome.ok ? { views: outcome.views } : failureDetails({ views: outcome.views }),
|
|
667
|
-
isError: !outcome.ok,
|
|
668
|
-
...toolUsageField(usage),
|
|
669
|
-
};
|
|
670
|
-
} catch (error) {
|
|
671
|
-
d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : "failed" });
|
|
672
|
-
throw error;
|
|
673
|
-
} finally {
|
|
674
|
-
d.clearStops(delRoot);
|
|
675
|
-
d.clearSteers(delRoot);
|
|
676
|
-
d.agentTree.remove(delRoot);
|
|
677
|
-
}
|
|
707
|
+
return {
|
|
708
|
+
content: [{ type: "text", text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }" }],
|
|
709
|
+
details: failureDetails({}),
|
|
710
|
+
isError: true,
|
|
711
|
+
};
|
|
678
712
|
},
|
|
679
713
|
|
|
680
714
|
renderCall(args, theme) {
|
|
681
715
|
const title = theme.fg("toolTitle", theme.bold("delegate "));
|
|
682
716
|
const coerced = coerceDelegateParams(args);
|
|
683
717
|
const view = coerced.ok ? coerced.params : undefined;
|
|
684
|
-
const mode = wantsAsyncRun(view ?? args, true) ? theme.fg("warning", " async") : theme.fg("dim", " sync");
|
|
685
718
|
if (view?.tasks && view.tasks.length > 0) {
|
|
686
719
|
// Names live in the tree / final card — keep the call line itself minimal.
|
|
687
|
-
return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}
|
|
720
|
+
return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}`, 0, 0);
|
|
688
721
|
}
|
|
689
722
|
if (typeof args.tasks === "string" && args.tasks.trim()) {
|
|
690
|
-
return new Text(`${title}${theme.fg("accent", "parallel")}
|
|
723
|
+
return new Text(`${title}${theme.fg("accent", "parallel")}`, 0, 0);
|
|
691
724
|
}
|
|
692
|
-
const
|
|
725
|
+
const aliasValue = (view?.name?.trim() ? view.name : undefined) ?? view?.agent ?? (typeof args.name === "string" ? args.name : undefined) ?? (typeof args.agent === "string" ? args.agent : "?");
|
|
726
|
+
const alias = compactInlineText(aliasValue || "?", { maxChars: 80 }) || "?";
|
|
693
727
|
const modelValue = view?.model ?? args.model;
|
|
694
|
-
const
|
|
695
|
-
|
|
728
|
+
const agent = compactInlineText(view?.agent ?? (typeof args.agent === "string" ? args.agent : ""), { maxChars: 40 });
|
|
729
|
+
const model = typeof modelValue === "string" && modelValue.trim() ? compactInlineText(modelValue, { maxChars: 80 }) : "";
|
|
730
|
+
const titleLine = `${title}${theme.fg("accent", theme.bold(compactVisibleLine(alias, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth("delegate "))))}`;
|
|
731
|
+
const metadata = [agent, model].filter(Boolean).join(" · ");
|
|
732
|
+
const metadataLine = metadata ? theme.fg("dim", compactVisibleLine(` ${metadata}`, MAX_COLLAPSED_CARD_COLUMNS)) : "";
|
|
733
|
+
return new Text([titleLine, metadataLine].filter(Boolean).join("\n"), 0, 0);
|
|
696
734
|
},
|
|
697
735
|
|
|
698
|
-
renderResult(result, { expanded }, theme, context) {
|
|
699
|
-
// Safe: the delegate `execute` above
|
|
700
|
-
//
|
|
736
|
+
renderResult(result, { expanded, isPartial: resultIsPartial }, theme, context) {
|
|
737
|
+
// Safe: the delegate `execute` above stores `{ runId | runIds, runs, dropped }` (interactive launch)
|
|
738
|
+
// or nothing (a headless call's report is its text) in `details`; the double cast just narrows
|
|
701
739
|
// Pi's opaque `details` type to that known shape for rendering.
|
|
702
|
-
const details = result.details as unknown as {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if (details?.runId || details?.runIds?.length)
|
|
716
|
-
|
|
717
|
-
|
|
740
|
+
const details = result.details as unknown as { runId?: string; runIds?: string[]; runs?: unknown; dropped?: number } | undefined;
|
|
741
|
+
// Native Pi supplies only content/details in result; the host owns the error flag.
|
|
742
|
+
const isError = context?.isError ?? result.isError;
|
|
743
|
+
const first = result.content[0];
|
|
744
|
+
const fallback = details?.runIds?.length
|
|
745
|
+
? `async runs ${details.runIds.join(", ")}`
|
|
746
|
+
: details?.runId
|
|
747
|
+
? `async run ${details.runId}`
|
|
748
|
+
: "(no output)";
|
|
749
|
+
const text = sanitizeTerminalText(first?.type === "text" ? first.text : fallback);
|
|
750
|
+
const allowTracker = context === undefined || context.isPartial === true;
|
|
751
|
+
const rows = details ? launchRows(details, d.tracker, allowTracker) : [];
|
|
752
|
+
if (expanded) {
|
|
753
|
+
if (details?.runId || details?.runIds?.length) return new Text(expandedLaunchDetails(text, rows), 0, 0);
|
|
754
|
+
// A headless report may contain failed legs and have empty details too. Only its
|
|
755
|
+
// runtime settlement envelope distinguishes that report from a host diagnostic.
|
|
756
|
+
const report = /^\[pi-persona\] \d+ async runs? settled — \d+ done, \d+ failed(?:, \d+ stopped)?(?:\n|$)/.test(text);
|
|
757
|
+
if (isError && !report) return new Text(theme.fg("toolOutput", text), 0, 0);
|
|
758
|
+
return createReportBody(text, theme, {}, context?.lastComponent);
|
|
759
|
+
}
|
|
760
|
+
if (details?.runId || details?.runIds?.length) {
|
|
761
|
+
if (rows.length > 0 && !isError) {
|
|
762
|
+
// A one-admitted-leg fan-out is still a fan-out when maxChildren dropped
|
|
763
|
+
// declared tasks; keep its truncation count in the collapsed receipt.
|
|
764
|
+
const hasDroppedTasks = typeof details.dropped === "number"
|
|
765
|
+
&& Number.isFinite(details.dropped)
|
|
766
|
+
&& details.dropped > 0;
|
|
767
|
+
if (rows.length === 1 && !hasDroppedTasks && context
|
|
768
|
+
&& matchesCompleteSingleCall(context.args, context.argsComplete, context.isPartial || resultIsPartial, rows[0]!)) {
|
|
769
|
+
return new Text(theme.fg("accent", theme.bold("launched")), 0, 0);
|
|
718
770
|
}
|
|
719
|
-
if (
|
|
720
|
-
const
|
|
721
|
-
return new Text(
|
|
771
|
+
if (rows.length === 1 && !hasDroppedTasks) {
|
|
772
|
+
const lines = singleLaunchReceipt(rows[0]!);
|
|
773
|
+
return new Text([
|
|
774
|
+
theme.fg("accent", theme.bold(lines[0] ?? "launched")),
|
|
775
|
+
...lines.slice(1).map((line) => theme.fg("dim", line)),
|
|
776
|
+
].join("\n"), 0, 0);
|
|
722
777
|
}
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
778
|
+
const lines = collapsedLaunchRows(rows, details.dropped);
|
|
779
|
+
return new Text(lines.map((line, index) => {
|
|
780
|
+
const prefix = index === 0
|
|
781
|
+
? theme.fg("toolTitle", theme.bold(line.prefix))
|
|
782
|
+
: theme.fg("dim", line.prefix);
|
|
783
|
+
const label = line.label ? theme.fg("accent", theme.bold(line.label)) : "";
|
|
784
|
+
const metadata = line.metadata ? theme.fg("dim", ` · ${line.metadata}`) : "";
|
|
785
|
+
const suffix = line.suffix ? theme.fg("dim", line.suffix) : "";
|
|
786
|
+
return `${prefix}${label}${metadata}${suffix}`;
|
|
787
|
+
}).join("\n"), 0, 0);
|
|
727
788
|
}
|
|
728
|
-
if (
|
|
789
|
+
if (isError) {
|
|
729
790
|
const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
|
|
730
|
-
return new Text(`${theme.fg("error", theme.bold("
|
|
731
|
-
}
|
|
732
|
-
return new Text(theme.fg("toolOutput", compactVisibleText(text, { maxLines: 4, maxLineChars: 100 }).text), 0, 0);
|
|
733
|
-
}
|
|
734
|
-
const title = theme.fg("toolTitle", theme.bold("delegate "));
|
|
735
|
-
const running = views.filter((v) => v.running).length;
|
|
736
|
-
// While running, render nothing — the live per-agent view is the tree widget
|
|
737
|
-
// (and the f9 overlay). A sticky card here would just duplicate it. The full
|
|
738
|
-
// per-leg cards below appear once the run completes.
|
|
739
|
-
if (running > 0) return new Container();
|
|
740
|
-
const okCount = views.filter((v) => v.ok).length;
|
|
741
|
-
const container = new Container();
|
|
742
|
-
container.addChild(new Text(`${title}${theme.fg("accent", `${okCount}/${views.length} ok`)}`, 0, 0));
|
|
743
|
-
// A failure is actionable; never bury it below a page of successful legs. Collapsed cards
|
|
744
|
-
// show at most three semantic one-line previews. Expansion remains the lossless inspection
|
|
745
|
-
// surface, while F9 keeps the live navigable tree.
|
|
746
|
-
const ordered = expanded ? views : [...views].sort((a, b) => Number(a.ok) - Number(b.ok));
|
|
747
|
-
const visible = expanded ? ordered : ordered.slice(0, 3);
|
|
748
|
-
for (const v of visible) {
|
|
749
|
-
const icon = v.ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
750
|
-
const usageStr = formatUsage(v.usage);
|
|
751
|
-
const usage = usageStr ? theme.fg("dim", ` ${usageStr}`) : "";
|
|
752
|
-
const body = sanitizeTerminalText(v.output || "(no output)");
|
|
753
|
-
if (expanded) {
|
|
754
|
-
container.addChild(new Spacer(1));
|
|
755
|
-
container.addChild(new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 96 }) || "agent")}${usage}`, 0, 0));
|
|
756
|
-
container.addChild(new Text(theme.fg("toolOutput", body), 0, 0));
|
|
757
|
-
continue;
|
|
791
|
+
return new Text(`${theme.fg("error", theme.bold("failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
|
|
758
792
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
container.addChild(
|
|
764
|
-
new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 28 }) || "agent")} · ${theme.fg("toolOutput", preview.text)}`, 0, 0),
|
|
765
|
-
);
|
|
793
|
+
const ids = details.runIds?.length ? details.runIds.slice(0, 3) : [details.runId as string];
|
|
794
|
+
const omitted = (details.runIds?.length ?? 0) - ids.length;
|
|
795
|
+
const dropped = details.dropped ? ` · ${details.dropped} task${details.dropped === 1 ? "" : "s"} dropped (max children)` : "";
|
|
796
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("delegate "))}${theme.fg("accent", `launched ${ids.join(", ")}${omitted > 0 ? `, +${omitted} more` : ""}`)}${theme.fg("dim", dropped)}`, 0, 0);
|
|
766
797
|
}
|
|
767
|
-
if (
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
container.addChild(new Text(theme.fg("dim", `${prefix}${expandDetailHint()}`), 0, 0));
|
|
798
|
+
if (isError) {
|
|
799
|
+
const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
|
|
800
|
+
return new Text(`${theme.fg("error", theme.bold("delegate failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
|
|
771
801
|
}
|
|
772
|
-
return
|
|
802
|
+
return new Text(theme.fg("toolOutput", compactVisibleText(text, { maxLines: 4, maxLineChars: 100 }).text), 0, 0);
|
|
773
803
|
},
|
|
774
804
|
});
|
|
775
805
|
}
|