@aeondave/pi-persona 1.15.2
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 +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/** `delegate` tool registration. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentConfig } from "../agents/agent.ts";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Container, Spacer } from "@earendil-works/pi-tui";
|
|
8
|
+
import { configuredModels } from "../extension/engine.ts";
|
|
9
|
+
import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
|
|
10
|
+
import { compactVisibleText } from "../ui/presentation.ts";
|
|
11
|
+
import { resolveModelRef } from "../core/models.ts";
|
|
12
|
+
import { inventedLegNameHint } from "../core/naming.ts";
|
|
13
|
+
import { formatUsage, toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
|
|
14
|
+
import { sumUsage } from "../orchestration/reducers.ts";
|
|
15
|
+
import { expandDetailHint, failureDetails } from "../extension/shared.ts";
|
|
16
|
+
import {
|
|
17
|
+
DelegationLedger, type DelegateView, type DelegateParams as DelegateCall, nameFor, normalizeDelegateConcurrency,
|
|
18
|
+
runDelegate, shortModel, shouldRecordDelegationOutcome, specOf, unknownAgentError, unknownContractError,
|
|
19
|
+
validateDelegationBrief, validateParallelWriteSets, wantsAsyncRun, coerceDelegateParams,
|
|
20
|
+
} from "./delegate.ts";
|
|
21
|
+
import { Semaphore } from "../orchestration/parallel.ts";
|
|
22
|
+
import type { AgentRunSpec, SteerFn, StrategyEngine } from "../orchestration/sdk.ts";
|
|
23
|
+
import type { AgentResult } from "../orchestration/types.ts";
|
|
24
|
+
import { agentNodeStatusForDelegate, sanitizeLabel } from "../extension/shared.ts";
|
|
25
|
+
import type { PersonaController } from "../persona/controller.ts";
|
|
26
|
+
import type { AgentTree } from "../ui/agent-tree.ts";
|
|
27
|
+
import type { AsyncRunTracker } from "../engine/async.ts";
|
|
28
|
+
import { emptyUsage, type ProgressSnapshot, type ToolEvent } from "../engine/stream.ts";
|
|
29
|
+
import { progressPatch } from "../ui/agent-tree.ts";
|
|
30
|
+
import type { AddNodeInput } from "../ui/agent-tree.ts";
|
|
31
|
+
import type { RunLimits } from "../core/capabilities.ts";
|
|
32
|
+
|
|
33
|
+
export interface DelegateToolDeps {
|
|
34
|
+
get lastCtx(): ExtensionContext | undefined;
|
|
35
|
+
set lastCtx(value: ExtensionContext | undefined);
|
|
36
|
+
controller: PersonaController;
|
|
37
|
+
agents: AgentConfig[];
|
|
38
|
+
contractNames: () => string[];
|
|
39
|
+
buildEngine: (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, opts?: { async?: boolean }) => StrategyEngine;
|
|
40
|
+
agentTree: AgentTree;
|
|
41
|
+
nextRootId: (prefix: string) => string;
|
|
42
|
+
tracker: AsyncRunTracker;
|
|
43
|
+
config: { ledgerV2: boolean };
|
|
44
|
+
RUN_LIMITS: RunLimits;
|
|
45
|
+
publishAgentTool(agentId: string, event: ToolEvent): void;
|
|
46
|
+
stopRegistry: Map<string, () => void>;
|
|
47
|
+
steerRegistry: Map<string, SteerFn>;
|
|
48
|
+
stopRequested: Set<string>;
|
|
49
|
+
ensurePersonaModels(ctx: ExtensionContext, roster: unknown[]): Promise<void>;
|
|
50
|
+
clearStops(prefix: string): void;
|
|
51
|
+
clearSteers(prefix: string): void;
|
|
52
|
+
drainBusBlock(): string;
|
|
53
|
+
startPeek(): void;
|
|
54
|
+
childUsage: ChildUsageLedger;
|
|
55
|
+
publishPersonaCost(): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): void {
|
|
59
|
+
// ── delegate tool (opportunistic L0) ────────────────────────────────────────
|
|
60
|
+
const JsonString = Type.String({
|
|
61
|
+
description: "JSON text of the same value — accepted when a client stringifies nested args",
|
|
62
|
+
});
|
|
63
|
+
const SkillsSchema = Type.Array(Type.String(), {
|
|
64
|
+
description: "Skills the sub-agent loads first — spawns a dynamic specialist (skills are inherited from the host)",
|
|
65
|
+
});
|
|
66
|
+
const RoleSchema = Type.String({
|
|
67
|
+
description:
|
|
68
|
+
"On-the-fly specialist persona: extra system-prompt text appended to the agent's own (e.g. 'You are a Rust unsafe-code auditor…') — combine with `skills` to shape a dynamic sub-agent without authoring a file",
|
|
69
|
+
});
|
|
70
|
+
const LeaderNameDescription = `${inventedLegNameHint()} As the supervisor, assign and pass this name before launch so the worker sees it from its first turn; omission remains valid and uses the configured agent's generic fallback.`;
|
|
71
|
+
const BriefListSchema = Type.Union([Type.String(), Type.Array(Type.String())]);
|
|
72
|
+
const DelegationBriefSchema = Type.Object({
|
|
73
|
+
objective: Type.String({ description: "Verifiable objective and success signal" }),
|
|
74
|
+
scopeRoe: Type.String({ description: "In-scope targets plus hard scope/authorization boundaries" }),
|
|
75
|
+
position: Type.String({ description: "Minimum starting state, foothold, credentials, or assumptions the worker may rely on" }),
|
|
76
|
+
constraints: BriefListSchema,
|
|
77
|
+
requiredArtifacts: BriefListSchema,
|
|
78
|
+
stopConditions: BriefListSchema,
|
|
79
|
+
});
|
|
80
|
+
const WriteSetSchema = Type.Array(Type.String(), {
|
|
81
|
+
description: "Repository-relative files/directories this leg alone may modify; parallel overlaps are rejected",
|
|
82
|
+
});
|
|
83
|
+
const DelegateTaskItem = Type.Object({
|
|
84
|
+
agent: Type.String({ description: 'Agent to run — use "operator" for a dynamic, skill-driven executor' }),
|
|
85
|
+
task: Type.String({ description: "Self-contained packet: objective, scope, allowed tools, success signal, non-goals" }),
|
|
86
|
+
brief: Type.Optional(Type.Union([DelegationBriefSchema, JsonString], {
|
|
87
|
+
description: "This worker's structured brief. Required when the active persona has requireBrief, including read-only scouts. Supply all six fields here in each tasks[] entry; task prose and a top-level brief do not replace it.",
|
|
88
|
+
})),
|
|
89
|
+
name: Type.Optional(
|
|
90
|
+
Type.String({ description: LeaderNameDescription }),
|
|
91
|
+
),
|
|
92
|
+
skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
|
|
93
|
+
role: Type.Optional(RoleSchema),
|
|
94
|
+
model: Type.Optional(
|
|
95
|
+
Type.String({ description: "Model override (exact provider/id — call the `models` tool to find one)" }),
|
|
96
|
+
),
|
|
97
|
+
tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override for this sub-agent; [] explicitly grants no tools" }), JsonString])),
|
|
98
|
+
isolation: Type.Optional(
|
|
99
|
+
Type.Union([Type.Literal("none"), Type.Literal("worktree")], { description: "worktree = run in an isolated git worktree (edits never touch the main tree)" }),
|
|
100
|
+
),
|
|
101
|
+
mcp: Type.Optional(
|
|
102
|
+
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." }),
|
|
103
|
+
),
|
|
104
|
+
timeoutMs: Type.Optional(
|
|
105
|
+
Type.Number({ description: "Idle timeout in ms for this worker: resets on progress, not a total runtime cap. A positive value overrides the shared default; zero or negative uses the default." }),
|
|
106
|
+
),
|
|
107
|
+
writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
|
|
108
|
+
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." })),
|
|
109
|
+
});
|
|
110
|
+
const DelegateParams = Type.Object({
|
|
111
|
+
agent: Type.Optional(Type.String({ description: "Agent to delegate to (single mode)" })),
|
|
112
|
+
task: Type.Optional(Type.String({ description: "Task for the agent (single mode)" })),
|
|
113
|
+
brief: Type.Optional(Type.Union([DelegationBriefSchema, JsonString], {
|
|
114
|
+
description: "Single-mode structured brief. Required when the active persona has requireBrief. For parallel mode, provide a separate complete brief inside every tasks[] entry instead; this top-level field is not shared with the batch.",
|
|
115
|
+
})),
|
|
116
|
+
name: Type.Optional(Type.String({ description: LeaderNameDescription })),
|
|
117
|
+
skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
|
|
118
|
+
role: Type.Optional(RoleSchema),
|
|
119
|
+
model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
|
|
120
|
+
tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override (single mode); [] explicitly grants no tools" }), JsonString])),
|
|
121
|
+
isolation: Type.Optional(
|
|
122
|
+
Type.Union([Type.Literal("none"), Type.Literal("worktree")], { description: "worktree = run the single sub-agent in an isolated git worktree" }),
|
|
123
|
+
),
|
|
124
|
+
mcp: Type.Optional(
|
|
125
|
+
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')" }),
|
|
126
|
+
),
|
|
127
|
+
timeoutMs: Type.Optional(
|
|
128
|
+
Type.Number({ description: "Single-mode idle timeout in ms: resets on progress, not a total runtime cap. A positive value overrides the shared default; zero or negative uses the default." }),
|
|
129
|
+
),
|
|
130
|
+
writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
|
|
131
|
+
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." })),
|
|
132
|
+
tasks: Type.Optional(
|
|
133
|
+
Type.Union([
|
|
134
|
+
Type.Array(Type.Union([DelegateTaskItem, JsonString])),
|
|
135
|
+
JsonString,
|
|
136
|
+
], { description: `Independent tasks as an array of objects (at most ${d.RUN_LIMITS.maxChildren} workers per call; split larger batches). Each entry carries its own agent, task, and any policy-required brief; top-level single-mode fields are not inherited. Give each a bounded scope; parallel writers need disjoint writeSet values.` }),
|
|
137
|
+
),
|
|
138
|
+
concurrency: Type.Optional(
|
|
139
|
+
Type.Integer({ minimum: 1, description: `Max children to run at once (default ${d.RUN_LIMITS.maxConcurrency}; larger requests are clamped)` }),
|
|
140
|
+
),
|
|
141
|
+
async: Type.Optional(
|
|
142
|
+
Type.Boolean({
|
|
143
|
+
description:
|
|
144
|
+
"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.",
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
sync: Type.Optional(
|
|
148
|
+
Type.Boolean({
|
|
149
|
+
description:
|
|
150
|
+
"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.",
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Canonicalise a delegate's requested model names to provider/id; return a clear
|
|
156
|
+
// error (no spawn) when one is ambiguous/unknown so the supervisor retries with a
|
|
157
|
+
// valid id instead of wasting a child on an unauthenticated provider.
|
|
158
|
+
function resolveDelegateModels(params: DelegateCall, ctx: ExtensionContext): string | undefined {
|
|
159
|
+
const models = configuredModels(ctx);
|
|
160
|
+
if (models.length === 0) return undefined;
|
|
161
|
+
const preferProvider = ctx.model?.provider; // the loader/session provider (the authenticated one)
|
|
162
|
+
const slots: Array<{ ref: string; set: (v: string) => void; who: string }> = [];
|
|
163
|
+
if (params.model) slots.push({ ref: params.model, set: (v) => { params.model = v; }, who: "the sub-agent" });
|
|
164
|
+
params.tasks?.forEach((t, i) => {
|
|
165
|
+
if (t.model) slots.push({ ref: t.model, set: (v) => { t.model = v; }, who: `task ${i + 1} (${t.agent})` });
|
|
166
|
+
});
|
|
167
|
+
for (const s of slots) {
|
|
168
|
+
const r = resolveModelRef(s.ref, models, preferProvider);
|
|
169
|
+
if (r.ok) {
|
|
170
|
+
s.set(r.ref);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const list = r.candidates.slice(0, 10).join(", ");
|
|
174
|
+
return `delegate: model "${s.ref}" for ${s.who} is ${r.reason} — use an exact model id. Candidates: ${list}${r.candidates.length > 10 ? ", …" : ""}.`;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The async launch pool: every background run passes through here, so a 20-task async
|
|
180
|
+
// fan-out respects the same concurrency ceiling a sync delegate does, instead of opening
|
|
181
|
+
// 20 model sessions at once. Queued runs show as "running" with no progress yet; stopping
|
|
182
|
+
// a queued run works (the engine settles a pre-aborted signal without a model call).
|
|
183
|
+
const asyncSlots = new Semaphore(d.RUN_LIMITS.maxConcurrency);
|
|
184
|
+
let asyncNameSequence = 0;
|
|
185
|
+
|
|
186
|
+
// Runtime anti-loop guard: an identical (agent, model, task) delegation that failed
|
|
187
|
+
// twice is vetoed BEFORE it spawns — the completion report's "don't re-issue" guidance
|
|
188
|
+
// is advice; this is the enforcement (capabilities are never prompt-only).
|
|
189
|
+
const ledger = new DelegationLedger({ ledgerV2: d.config.ledgerV2 });
|
|
190
|
+
|
|
191
|
+
// Only the built-in inspection tools are known read-only. Shells and unknown/custom tools are
|
|
192
|
+
// potential writers; treating them as readers would let a persona's ownership policy fail open
|
|
193
|
+
// merely by moving an edit into a script. ONE classifier for the in-batch gates and the
|
|
194
|
+
// cross-call one below, so a leg can never count as a writer in one and a reader in the other.
|
|
195
|
+
// `skills`/`role` are deliberately NOT part of this: both are prompt text (a skills preamble and an
|
|
196
|
+
// appended system prompt), while `tools` is an enforced session allowlist in both engines — a leg
|
|
197
|
+
// granted only read/grep/find/ls cannot write however it is instructed. Classifying on them would
|
|
198
|
+
// invent writers that provably cannot write, and under requireDisjointWrites that is a hard refusal.
|
|
199
|
+
const readOnlyTools = new Set(["read", "grep", "find", "ls"]);
|
|
200
|
+
function mayMutateWorkspace(spec: { agent: string; tools?: string[] | undefined; mcp?: boolean | undefined }): boolean {
|
|
201
|
+
const configured = d.agents.find((agent) => agent.name === spec.agent);
|
|
202
|
+
const effectiveTools = spec.tools !== undefined ? spec.tools : configured?.tools;
|
|
203
|
+
const effectiveMcp = spec.mcp ?? configured?.mcp;
|
|
204
|
+
return effectiveMcp === true || effectiveTools === undefined || effectiveTools.some((tool) => !readOnlyTools.has(tool));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Launch one agent in the background (tracked) and add its live async node to the tree.
|
|
208
|
+
// `label` is the bare codename (nameFor) — the model is folded in here (and stored on the
|
|
209
|
+
// d.tracker entry) so the tree node and every intercom digest show the SAME composed name.
|
|
210
|
+
function launchAsyncRun(agent: string, task: string, runSpec: AgentRunSpec, label: string, batchSlots?: Semaphore): string {
|
|
211
|
+
const model = shortModel(runSpec.model);
|
|
212
|
+
// The writer classification travels WITH the run (d.tracker metadata), not in a side Set keyed by
|
|
213
|
+
// the returned id: a thunk that throws synchronously settles the run inside launch(), so a
|
|
214
|
+
// registration after launch() returns would re-insert an already-dead run and leak it.
|
|
215
|
+
const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec) }, (onProgress, runId) => {
|
|
216
|
+
const nodeId = `async:${runId}`;
|
|
217
|
+
// A real, HARD stop for the async run (a steer is only a soft request the child may
|
|
218
|
+
// ignore): aborting this signal makes the engine call the sub-agent's `agent.abort()`.
|
|
219
|
+
const ac = new AbortController();
|
|
220
|
+
d.stopRegistry.set(nodeId, () => ac.abort());
|
|
221
|
+
const execute = () =>
|
|
222
|
+
asyncSlots.with(() =>
|
|
223
|
+
d.buildEngine(
|
|
224
|
+
undefined,
|
|
225
|
+
(snap) => {
|
|
226
|
+
onProgress(snap);
|
|
227
|
+
if (snap.toolEvent) d.publishAgentTool(nodeId, snap.toolEvent);
|
|
228
|
+
d.agentTree.update(nodeId, progressPatch(snap, Date.now()));
|
|
229
|
+
},
|
|
230
|
+
{ async: true },
|
|
231
|
+
// STOP via `ac.signal` (hard abort) and STEER via the run-id key (soft redirect) —
|
|
232
|
+
// both work for the supervisor (intercom `stop`/`steer`) and the f9 overlay (`x`/`s`),
|
|
233
|
+
// for ANY persona (these are supervisor→child controls, not child tools).
|
|
234
|
+
).run(runSpec, undefined, ac.signal, (steer) => {
|
|
235
|
+
d.steerRegistry.set(nodeId, steer);
|
|
236
|
+
// live now — clear the "queued" marker and re-stamp the clock from run time, not
|
|
237
|
+
// the queue-time seed, so the elapsed reading and stall badge are both honest.
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
d.agentTree.update(nodeId, { detail: "", startedAt: now, lastAdvanceAt: now });
|
|
240
|
+
}),
|
|
241
|
+
ac.signal,
|
|
242
|
+
);
|
|
243
|
+
// A per-call semaphore composes with the process-wide ceiling. Acquire it first so a
|
|
244
|
+
// queued member of a serial batch never occupies a global slot while waiting for its
|
|
245
|
+
// predecessor. This makes `concurrency: 1` mean the same thing in sync and async mode.
|
|
246
|
+
return (batchSlots ? batchSlots.with(execute, ac.signal) : execute())
|
|
247
|
+
.catch((error: unknown): AgentResult => {
|
|
248
|
+
if (!ac.signal.aborted) throw error;
|
|
249
|
+
return { agent, output: "", usage: emptyUsage(), ok: false, error: "agent aborted", failureKind: "abort" };
|
|
250
|
+
})
|
|
251
|
+
.then((r) => {
|
|
252
|
+
if (shouldRecordDelegationOutcome(r)) {
|
|
253
|
+
ledger.record(
|
|
254
|
+
{
|
|
255
|
+
agent,
|
|
256
|
+
...(runSpec.model ? { model: runSpec.model } : {}),
|
|
257
|
+
task,
|
|
258
|
+
...(runSpec.role ? { role: runSpec.role } : {}),
|
|
259
|
+
...(runSpec.tools !== undefined ? { tools: runSpec.tools } : {}),
|
|
260
|
+
...(runSpec.isolation ? { isolation: runSpec.isolation } : {}),
|
|
261
|
+
},
|
|
262
|
+
r.ok,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return r;
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
const nodeId = `async:${id}`;
|
|
269
|
+
// "queued" until the semaphore grants a slot and the engine reports it steerable. Every
|
|
270
|
+
// `async:*` node IS async by construction, so no "(async)" tag is needed — fold in the
|
|
271
|
+
// model instead, matching the canonical `<codename> · <model>` name shown elsewhere.
|
|
272
|
+
d.agentTree.add({
|
|
273
|
+
id: nodeId,
|
|
274
|
+
label: model ? `${label} · ${model}` : label,
|
|
275
|
+
status: "running",
|
|
276
|
+
kind: "subagent",
|
|
277
|
+
agent,
|
|
278
|
+
...(runSpec.model ? { model: runSpec.model } : {}),
|
|
279
|
+
detail: "queued",
|
|
280
|
+
});
|
|
281
|
+
d.startPeek(); // arm the timed supervisor wakeup while this run is in flight (no-op if PI_PERSONA_PEEK_MS=0)
|
|
282
|
+
return id;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
pi.registerTool({
|
|
286
|
+
name: "delegate",
|
|
287
|
+
label: "Delegate",
|
|
288
|
+
description: [
|
|
289
|
+
"Delegate work to sub-agents — your default move whenever a task has independent, heavy, or parallel parts.",
|
|
290
|
+
"Pass agent and task for one worker, or a tasks array for parallel workers. Follow the active persona's delegation policy for required fields.",
|
|
291
|
+
"When requireBrief is active, EVERY worker needs brief: { objective, scopeRoe, position, constraints, requiredArtifacts, stopConditions } with nonempty values, including read-only scouts. In parallel mode put it in each tasks[].brief; task prose and a top-level brief do not substitute. Read the current per-turn delegation brief for a complete example.",
|
|
292
|
+
"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.",
|
|
293
|
+
"In interactive sessions it runs in the BACKGROUND by default: you get run ids at once, stay free,",
|
|
294
|
+
"and each result returns to you automatically as a follow-up — do NOT poll (`intercom wait` only when",
|
|
295
|
+
"you need a result before your very next step; `sync: true` to block instead; headless runs default to sync).",
|
|
296
|
+
"No fitting agent? Shape one on the fly: `operator` + `role` (extra system prompt) + `skills`.",
|
|
297
|
+
"Assign each worker's name before launch so it sees the same identity from its first turn; omitted names keep the generic fallback.",
|
|
298
|
+
"A `model` may be a loose name ('sonnet') — it resolves to YOUR provider's id; ambiguous names return",
|
|
299
|
+
"candidates (or call `models`). Other options: name, tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
|
|
300
|
+
].join(" "),
|
|
301
|
+
parameters: DelegateParams,
|
|
302
|
+
async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
|
|
303
|
+
d.lastCtx = ctx;
|
|
304
|
+
const coerced = coerceDelegateParams(rawParams);
|
|
305
|
+
if (!coerced.ok) return { content: [{ type: "text", text: coerced.error }], details: failureDetails({}), isError: true };
|
|
306
|
+
let params = coerced.params;
|
|
307
|
+
const policy = d.controller.activePersona?.delegation;
|
|
308
|
+
const defaultOutputContract = policy?.outputContract;
|
|
309
|
+
if (defaultOutputContract) {
|
|
310
|
+
params = params.tasks && params.tasks.length > 0
|
|
311
|
+
? {
|
|
312
|
+
...params,
|
|
313
|
+
tasks: params.tasks.map((task) => task.outputContract?.trim() ? task : { ...task, outputContract: defaultOutputContract }),
|
|
314
|
+
}
|
|
315
|
+
: params.agent && params.task && !params.outputContract?.trim()
|
|
316
|
+
? { ...params, outputContract: defaultOutputContract }
|
|
317
|
+
: params;
|
|
318
|
+
}
|
|
319
|
+
if (policy?.requireBrief) {
|
|
320
|
+
const briefError = validateDelegationBrief(params);
|
|
321
|
+
if (briefError) return { content: [{ type: "text", text: briefError }], details: failureDetails({}), isError: true };
|
|
322
|
+
}
|
|
323
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
324
|
+
const effectiveConcurrency = normalizeDelegateConcurrency(params.concurrency, d.RUN_LIMITS.maxConcurrency);
|
|
325
|
+
const classified = params.tasks.map((task, index) => ({ task, index, mayWrite: mayMutateWorkspace(task) }));
|
|
326
|
+
const writing = classified.filter((entry) => entry.mayWrite);
|
|
327
|
+
if (policy?.requireFreshVerification && policy.verificationAgents && policy.verificationAgents.length > 0) {
|
|
328
|
+
const verifierNames = new Set(policy.verificationAgents);
|
|
329
|
+
const droppedVerifiers = params.tasks
|
|
330
|
+
.slice(d.RUN_LIMITS.maxChildren)
|
|
331
|
+
.filter((task) => verifierNames.has(task.agent));
|
|
332
|
+
if (droppedVerifiers.length > 0) {
|
|
333
|
+
const names = [...new Set(droppedVerifiers.map((task) => `"${task.agent}"`))].join(", ");
|
|
334
|
+
const message =
|
|
335
|
+
`delegate: the max-children limit (${d.RUN_LIMITS.maxChildren}) would truncate declared fresh verifier ${names}. ` +
|
|
336
|
+
"Split the mutations into smaller batches and run every verifier after the final material mutation; no partial batch was started.";
|
|
337
|
+
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
338
|
+
}
|
|
339
|
+
const verifiers = classified.filter(({ task }) => verifierNames.has(task.agent));
|
|
340
|
+
// A declared verifier may itself have `bash` for running tests; that makes it a
|
|
341
|
+
// potential filesystem writer for ownership purposes, but not the material mutation
|
|
342
|
+
// it is meant to approve. Compare it only with the other mutating roles here.
|
|
343
|
+
const mutations = writing.filter(({ task }) => !verifierNames.has(task.agent));
|
|
344
|
+
const lastMutation = mutations.reduce((last, entry) => Math.max(last, entry.index), -1);
|
|
345
|
+
const staleOrder = effectiveConcurrency === 1 && verifiers.some(({ index }) => index <= lastMutation);
|
|
346
|
+
if (mutations.length > 0 && verifiers.length > 0 && (effectiveConcurrency > 1 || staleOrder)) {
|
|
347
|
+
const names = verifiers.map(({ index, task }) => `tasks[${index}] ("${task.agent}")`).join(", ");
|
|
348
|
+
const reason = effectiveConcurrency > 1
|
|
349
|
+
? "would overlap a material mutation"
|
|
350
|
+
: "is ordered before a material mutation";
|
|
351
|
+
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, or intercom { action:"wait", to:"<run-id>" }) — a later call while a writer is still running is rejected the same way.`;
|
|
352
|
+
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (effectiveConcurrency > 1) {
|
|
356
|
+
if (policy?.requireDisjointWrites) {
|
|
357
|
+
if (writing.length > 1) {
|
|
358
|
+
const missing = writing.filter(({ task }) => !task.writeSet?.some((path) => path.trim())).map(({ index, task }) => `tasks[${index}] ("${task.agent}")`);
|
|
359
|
+
if (missing.length > 0) {
|
|
360
|
+
const message = `delegate: this persona requires disjoint ownership for parallel writers; missing non-empty writeSet on ${missing.join(", ")}. Declare repository-relative paths each of those legs alone may edit, restrict tools to read/grep/find/ls (or pick scout), or serialize with concurrency: 1.`;
|
|
361
|
+
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const writeSetError = validateParallelWriteSets(params.tasks);
|
|
366
|
+
if (writeSetError) return { content: [{ type: "text", text: writeSetError }], details: failureDetails({}), isError: true };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// The gate above sees ONE call. Interactive delegate is background by default, so the
|
|
370
|
+
// remedy it prescribes — run the verifier in a later call — lands WHILE the mutation is
|
|
371
|
+
// still running unless the same rule holds across calls; the policy would otherwise be
|
|
372
|
+
// defeated by following its own instructions. Guidance is never the enforcement here
|
|
373
|
+
// (capabilities are runtime-checked), so a verifier waits for the writers to settle.
|
|
374
|
+
if (policy?.requireFreshVerification && policy.verificationAgents && policy.verificationAgents.length > 0) {
|
|
375
|
+
const verifierNames = new Set(policy.verificationAgents);
|
|
376
|
+
const requestedAgents = params.tasks && params.tasks.length > 0 ? params.tasks.map((t) => t.agent) : params.agent ? [params.agent] : [];
|
|
377
|
+
if (requestedAgents.some((agent) => verifierNames.has(agent))) {
|
|
378
|
+
// A declared verifier's own background legs are not the mutation it must approve
|
|
379
|
+
// (same carve-out the in-batch gate makes for a test-running verifier).
|
|
380
|
+
const liveMutations = d.tracker.writers().filter((run) => !verifierNames.has(run.agent));
|
|
381
|
+
if (liveMutations.length > 0) {
|
|
382
|
+
const verifierList = [...new Set(requestedAgents.filter((agent) => verifierNames.has(agent)))].map((agent) => `"${agent}"`).join(", ");
|
|
383
|
+
const visibleMutations = liveMutations.slice(0, 8);
|
|
384
|
+
const omittedMutations = liveMutations.length - visibleMutations.length;
|
|
385
|
+
const inFlight = `${visibleMutations.map((run) => `${run.id} (${sanitizeLabel(run.agent)})`).join(", ")}${omittedMutations > 0 ? `, … +${omittedMutations} more` : ""}`;
|
|
386
|
+
const message =
|
|
387
|
+
`delegate: fresh verification must run after every material mutation; ${verifierList} cannot start while ${inFlight} ${liveMutations.length === 1 ? "is" : "are"} still mutating. ` +
|
|
388
|
+
`Wait for the completion follow-up (or intercom { action:"wait", to:"${liveMutations[0]?.id}" }), then start the verifier against the resulting state.`;
|
|
389
|
+
return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const modelErr = resolveDelegateModels(params, ctx);
|
|
394
|
+
if (modelErr) return { content: [{ type: "text", text: modelErr }], details: failureDetails({}), isError: true };
|
|
395
|
+
// Pre-spawn agent validation (mirrors the model path): a wrong name returns the
|
|
396
|
+
// installed list instead of spawning into a bare engine failure, and a typo never
|
|
397
|
+
// counts toward the ledger's 2-strike veto.
|
|
398
|
+
const agentErr = unknownAgentError(
|
|
399
|
+
params.tasks && params.tasks.length > 0 ? params.tasks.map((t) => t.agent) : params.agent ? [params.agent] : [],
|
|
400
|
+
d.agents.map((a) => a.name),
|
|
401
|
+
);
|
|
402
|
+
if (agentErr) return { content: [{ type: "text", text: agentErr }], details: failureDetails({}), isError: true };
|
|
403
|
+
// Same pre-spawn rule for output contracts: a prose description or a typo here used to become a
|
|
404
|
+
// runtime engine failure on every leg; now it is one rejection that names the installed contracts.
|
|
405
|
+
const contractErr = unknownContractError(
|
|
406
|
+
params.tasks && params.tasks.length > 0 ? params.tasks.map((t) => t.outputContract) : [params.outputContract],
|
|
407
|
+
d.contractNames(),
|
|
408
|
+
);
|
|
409
|
+
if (contractErr) return { content: [{ type: "text", text: contractErr }], details: failureDetails({}), isError: true };
|
|
410
|
+
// Anti-loop veto (after model canonicalisation, so keys match retries): an
|
|
411
|
+
// identical delegation that already failed twice does not spawn again.
|
|
412
|
+
const requested =
|
|
413
|
+
params.tasks && params.tasks.length > 0
|
|
414
|
+
? params.tasks.map((t) => ({
|
|
415
|
+
agent: t.agent,
|
|
416
|
+
...(t.model ? { model: t.model } : {}),
|
|
417
|
+
task: t.task,
|
|
418
|
+
...(t.role ? { role: t.role } : {}),
|
|
419
|
+
...(t.tools !== undefined ? { tools: t.tools } : {}),
|
|
420
|
+
...(t.isolation ? { isolation: t.isolation } : {}),
|
|
421
|
+
}))
|
|
422
|
+
: params.agent && params.task
|
|
423
|
+
? [
|
|
424
|
+
{
|
|
425
|
+
agent: params.agent,
|
|
426
|
+
...(params.model ? { model: params.model } : {}),
|
|
427
|
+
task: params.task,
|
|
428
|
+
...(params.role ? { role: params.role } : {}),
|
|
429
|
+
...(params.tools !== undefined ? { tools: params.tools } : {}),
|
|
430
|
+
...(params.isolation ? { isolation: params.isolation } : {}),
|
|
431
|
+
},
|
|
432
|
+
]
|
|
433
|
+
: [];
|
|
434
|
+
const veto = ledger.vet(requested);
|
|
435
|
+
if (veto) return { content: [{ type: "text", text: veto }], details: failureDetails({}), isError: true };
|
|
436
|
+
// Background by default in interactive sessions: the supervisor stays free and results
|
|
437
|
+
// return as follow-ups (the idle-gated push path). Headless (`pi -p`) defaults to sync —
|
|
438
|
+
// the single turn must carry the result, and nothing drains a follow-up after the
|
|
439
|
+
// process exits. An explicit `async` always wins; `sync: true` opts one call out.
|
|
440
|
+
const wantsAsync = wantsAsyncRun(params, ctx.hasUI === true);
|
|
441
|
+
// Async (single OR parallel): run in the background so YOU stay free to keep
|
|
442
|
+
// working / answer the user — results arrive later as follow-ups; /peek to watch.
|
|
443
|
+
if (wantsAsync && params.tasks && params.tasks.length > 0) {
|
|
444
|
+
const tasks = params.tasks.slice(0, d.RUN_LIMITS.maxChildren);
|
|
445
|
+
const dropped = params.tasks.length - tasks.length;
|
|
446
|
+
const effectiveConcurrency = normalizeDelegateConcurrency(params.concurrency, d.RUN_LIMITS.maxConcurrency);
|
|
447
|
+
// Avoid an extra scheduling hop when the requested limit cannot bind (also keeps a
|
|
448
|
+
// one-leg background launch observably immediate, as it was before per-call limits).
|
|
449
|
+
const batchSlots = effectiveConcurrency < tasks.length ? new Semaphore(effectiveConcurrency) : undefined;
|
|
450
|
+
const nameOffset = asyncNameSequence;
|
|
451
|
+
asyncNameSequence += tasks.length;
|
|
452
|
+
const ids = tasks.map((t, i) => {
|
|
453
|
+
// Routed through specOf() (not a hand-rolled field list) so this, the interactive
|
|
454
|
+
// DEFAULT delegate path, never drifts from the sync path's mapping — NP2's per-leg
|
|
455
|
+
// `timeoutMs` (and any future knob) lands here for free instead of needing a second copy.
|
|
456
|
+
const spec = specOf(t, nameOffset + i);
|
|
457
|
+
return launchAsyncRun(t.agent, t.task, spec, nameFor(t, nameOffset + i), batchSlots);
|
|
458
|
+
});
|
|
459
|
+
const droppedNote = dropped > 0 ? ` ${dropped} task(s) beyond the max-children limit (${d.RUN_LIMITS.maxChildren}) were dropped.` : "";
|
|
460
|
+
return {
|
|
461
|
+
content: [
|
|
462
|
+
{
|
|
463
|
+
type: "text",
|
|
464
|
+
text: `Launched ${ids.length} async runs in the background (${ids.join(", ")}) — keep working; each notifies on completion. /peek to watch.${droppedNote}`,
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
details: { runIds: ids },
|
|
468
|
+
isError: false,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (wantsAsync && params.agent && params.task) {
|
|
472
|
+
const agent = params.agent;
|
|
473
|
+
const task = params.task;
|
|
474
|
+
const nameIndex = asyncNameSequence++;
|
|
475
|
+
// Use the canonical mapper here too: explicit `none`/`false` and future fields must
|
|
476
|
+
// survive exactly as they do in fan-out and sync mode.
|
|
477
|
+
const single = { ...params, agent, task };
|
|
478
|
+
const runSpec = specOf(single, nameIndex);
|
|
479
|
+
const id = launchAsyncRun(agent, task, runSpec, nameFor(single, nameIndex));
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: "text",
|
|
484
|
+
text: `Launched async run ${id} (${agent}) — runs in the background; you'll be notified on completion. /peek ${id} to watch.`,
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
details: { runId: id },
|
|
488
|
+
isError: false,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const delRoot = `delegate:${_toolCallId}`;
|
|
492
|
+
d.agentTree.add({ id: delRoot, label: "delegate", status: "running", kind: "delegate" });
|
|
493
|
+
try {
|
|
494
|
+
const delegateLimits = { maxConcurrency: d.RUN_LIMITS.maxConcurrency, maxChildren: d.RUN_LIMITS.maxChildren };
|
|
495
|
+
const outcome = await runDelegate(
|
|
496
|
+
params,
|
|
497
|
+
d.buildEngine(signal),
|
|
498
|
+
delegateLimits,
|
|
499
|
+
(views) => {
|
|
500
|
+
views.forEach((v, i) => {
|
|
501
|
+
const id = `${delRoot}/${i}`;
|
|
502
|
+
if (!v.running) {
|
|
503
|
+
d.stopRegistry.delete(id);
|
|
504
|
+
d.stopRequested.delete(id);
|
|
505
|
+
d.steerRegistry.delete(id);
|
|
506
|
+
}
|
|
507
|
+
const status = agentNodeStatusForDelegate(v);
|
|
508
|
+
const spec = requested[i];
|
|
509
|
+
const node: AddNodeInput = {
|
|
510
|
+
id,
|
|
511
|
+
label: v.label,
|
|
512
|
+
parentId: delRoot,
|
|
513
|
+
status,
|
|
514
|
+
kind: "subagent",
|
|
515
|
+
...(spec?.agent ? { agent: spec.agent } : {}),
|
|
516
|
+
...(spec?.model ? { model: spec.model } : {}),
|
|
517
|
+
};
|
|
518
|
+
node.detail = v.running ? v.activity : formatUsage(v.usage);
|
|
519
|
+
if (v.output) node.output = v.output;
|
|
520
|
+
d.agentTree.add(node);
|
|
521
|
+
});
|
|
522
|
+
const done = views.filter((v) => !v.running).length;
|
|
523
|
+
onUpdate?.({ content: [{ type: "text", text: `delegate: ${done}/${views.length} done` }], details: { views } });
|
|
524
|
+
},
|
|
525
|
+
(i, abort) => d.stopRegistry.set(`${delRoot}/${i}`, abort),
|
|
526
|
+
(i, steer) => d.steerRegistry.set(`${delRoot}/${i}`, steer),
|
|
527
|
+
// The same run signal the engine was built with: a leg whose engine REJECTS under a
|
|
528
|
+
// whole-run stop must file as "abort", not as an agent failure the user never caused.
|
|
529
|
+
signal,
|
|
530
|
+
(i, event) => d.publishAgentTool(`${delRoot}/${i}`, event),
|
|
531
|
+
);
|
|
532
|
+
// Feed the anti-loop ledger (results align with the requested tasks by index).
|
|
533
|
+
outcome.results.forEach((r, i) => {
|
|
534
|
+
const t = requested[i];
|
|
535
|
+
if (t && shouldRecordDelegationOutcome(r)) ledger.record(t, r.ok);
|
|
536
|
+
});
|
|
537
|
+
d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
|
|
538
|
+
const usage = sumUsage(outcome.results.map((r) => r.usage));
|
|
539
|
+
d.childUsage.account(usage);
|
|
540
|
+
d.publishPersonaCost();
|
|
541
|
+
return {
|
|
542
|
+
// Sub-agent text is untrusted even as a tool result (guardrails §: fence
|
|
543
|
+
// before it reaches the supervisor) — the async path already fences via
|
|
544
|
+
// buildCompletionReport; the sync path must match.
|
|
545
|
+
content: [{ type: "text", text: `${fenceUntrusted(outcome.text)}${d.drainBusBlock()}` }],
|
|
546
|
+
details: outcome.ok ? { views: outcome.views } : failureDetails({ views: outcome.views }),
|
|
547
|
+
isError: !outcome.ok,
|
|
548
|
+
...toolUsageField(usage),
|
|
549
|
+
};
|
|
550
|
+
} catch (error) {
|
|
551
|
+
d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : "failed" });
|
|
552
|
+
throw error;
|
|
553
|
+
} finally {
|
|
554
|
+
d.clearStops(delRoot);
|
|
555
|
+
d.clearSteers(delRoot);
|
|
556
|
+
d.agentTree.remove(delRoot);
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
renderCall(args, theme) {
|
|
561
|
+
const title = theme.fg("toolTitle", theme.bold("delegate "));
|
|
562
|
+
const coerced = coerceDelegateParams(args);
|
|
563
|
+
const view = coerced.ok ? coerced.params : undefined;
|
|
564
|
+
if (view?.tasks && view.tasks.length > 0) {
|
|
565
|
+
// Names live in the tree / final card — keep the call line itself minimal.
|
|
566
|
+
return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}`, 0, 0);
|
|
567
|
+
}
|
|
568
|
+
if (typeof args.tasks === "string" && args.tasks.trim()) {
|
|
569
|
+
return new Text(`${title}${theme.fg("accent", "parallel")}`, 0, 0);
|
|
570
|
+
}
|
|
571
|
+
const agent = compactInlineText(view?.agent ?? args.agent ?? "?", { maxChars: 80 }) || "?";
|
|
572
|
+
const preview = compactInlineText(view?.task ?? args.task ?? "", { maxChars: 60 });
|
|
573
|
+
// renderCall only fires in an interactive UI, where delegate runs in the BACKGROUND by
|
|
574
|
+
// default — pass hasUI:true to the same wantsAsyncRun the execute path uses, so the common
|
|
575
|
+
// (defaulted) background run still shows the tag; `sync: true` drops it.
|
|
576
|
+
const asyncTag = wantsAsyncRun(view ?? args, true) ? theme.fg("warning", " async") : "";
|
|
577
|
+
return new Text(`${title}${theme.fg("accent", agent)}${asyncTag}${theme.fg("dim", ` ${preview}`)}`, 0, 0);
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
renderResult(result, { expanded }, theme) {
|
|
581
|
+
// Safe: the delegate `execute` above always stores `{ views: DelegateView[] }` (sync,
|
|
582
|
+
// single/parallel) or `{ runId | runIds }` (async) in `details`; the double cast just narrows
|
|
583
|
+
// Pi's opaque `details` type to that known shape for rendering.
|
|
584
|
+
const details = result.details as unknown as { views?: DelegateView[]; runId?: string; runIds?: string[] } | undefined;
|
|
585
|
+
const views = details?.views ?? [];
|
|
586
|
+
if (views.length === 0) {
|
|
587
|
+
const first = result.content[0];
|
|
588
|
+
const fallback = details?.runIds?.length
|
|
589
|
+
? `async runs ${details.runIds.join(", ")}`
|
|
590
|
+
: details?.runId
|
|
591
|
+
? `async run ${details.runId}`
|
|
592
|
+
: "(no output)";
|
|
593
|
+
const text = first?.type === "text" ? first.text : fallback;
|
|
594
|
+
return new Text(sanitizeTerminalText(text), 0, 0);
|
|
595
|
+
}
|
|
596
|
+
const title = theme.fg("toolTitle", theme.bold("delegate "));
|
|
597
|
+
const running = views.filter((v) => v.running).length;
|
|
598
|
+
// While running, render nothing — the live per-agent view is the tree widget
|
|
599
|
+
// (and the f9 overlay). A sticky card here would just duplicate it. The full
|
|
600
|
+
// per-leg cards below appear once the run completes.
|
|
601
|
+
if (running > 0) return new Container();
|
|
602
|
+
const okCount = views.filter((v) => v.ok).length;
|
|
603
|
+
const container = new Container();
|
|
604
|
+
container.addChild(new Text(`${title}${theme.fg("accent", `${okCount}/${views.length} ok`)}`, 0, 0));
|
|
605
|
+
// A failure is actionable; never bury it below a page of successful legs. Collapsed cards
|
|
606
|
+
// show at most three semantic one-line previews. Expansion remains the lossless inspection
|
|
607
|
+
// surface, while F9 keeps the live navigable tree.
|
|
608
|
+
const ordered = expanded ? views : [...views].sort((a, b) => Number(a.ok) - Number(b.ok));
|
|
609
|
+
const visible = expanded ? ordered : ordered.slice(0, 3);
|
|
610
|
+
for (const v of visible) {
|
|
611
|
+
const icon = v.ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
612
|
+
const usageStr = formatUsage(v.usage);
|
|
613
|
+
const usage = usageStr ? theme.fg("dim", ` ${usageStr}`) : "";
|
|
614
|
+
const body = sanitizeTerminalText(v.output || "(no output)");
|
|
615
|
+
if (expanded) {
|
|
616
|
+
container.addChild(new Spacer(1));
|
|
617
|
+
container.addChild(new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 96 }) || "agent")}${usage}`, 0, 0));
|
|
618
|
+
container.addChild(new Text(theme.fg("toolOutput", body), 0, 0));
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// Keep the collapsed semantic row below 100 columns even on a very wide terminal.
|
|
622
|
+
// Usage remains one keystroke away in expanded mode; the collapsed card prioritizes
|
|
623
|
+
// identity + outcome instead of wrapping one result into several pseudo-rows.
|
|
624
|
+
const preview = compactVisibleText(body, { maxLines: 1, maxLineChars: 60 });
|
|
625
|
+
container.addChild(
|
|
626
|
+
new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 28 }) || "agent")} · ${theme.fg("toolOutput", preview.text)}`, 0, 0),
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (!expanded) {
|
|
630
|
+
const omitted = views.length - visible.length;
|
|
631
|
+
const prefix = omitted > 0 ? `… +${omitted} more result${omitted === 1 ? "" : "s"} · ` : "";
|
|
632
|
+
container.addChild(new Text(theme.fg("dim", `${prefix}${expandDetailHint()}`), 0, 0));
|
|
633
|
+
}
|
|
634
|
+
return container;
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
}
|