@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,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InProcessEngine (v0.4) — a second `StrategyEngine` backend that runs a sub-agent
|
|
3
|
+
* IN THIS PROCESS via Pi's `createAgentSession`, instead of spawning `pi --mode
|
|
4
|
+
* json -p`. Same seam (`run` → `AgentResult`), same stream parser (`stream.ts`
|
|
5
|
+
* folds the session's `AgentEvent`s), same contract validation. This is what
|
|
6
|
+
* unlocks live **steering** (`agent.steer`) and drops the per-child spawn.
|
|
7
|
+
*
|
|
8
|
+
* Fork-bomb guard: the sub-session must NOT re-activate pi-persona (which would give
|
|
9
|
+
* the child the `delegate`/`council` tools + turn interception → recursion). We set
|
|
10
|
+
* `PI_PERSONA_DISABLE` for the duration of session creation (the same flag the child
|
|
11
|
+
* path passes via env) and exclude the orchestration tools defensively.
|
|
12
|
+
*
|
|
13
|
+
* The session factory is injectable (`deps.createSession`) — exactly like the child
|
|
14
|
+
* engine's `resolveInvocation` — so the whole path is deterministically testable with
|
|
15
|
+
* a fake session, without a live model. The default builds a real `createAgentSession`.
|
|
16
|
+
*
|
|
17
|
+
* This is the DEFAULT backend; `PI_PERSONA_ENGINE=child` opts back to the spawn-based
|
|
18
|
+
* `ChildProcessEngine`. It drops in behind the seam without touching strategies/UI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createAgentSession, DefaultResourceLoader, type ModelRegistry, SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import type { AgentConfig } from "../agents/agent.ts";
|
|
24
|
+
import type { InProcessBus } from "../bus/inproc.ts";
|
|
25
|
+
import { makeContactSupervisorTool } from "../bus/contact.ts";
|
|
26
|
+
import { makeContactPeerTool } from "../bus/peers.ts";
|
|
27
|
+
import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
|
|
28
|
+
import { sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
29
|
+
import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
30
|
+
import { attributeInbound } from "../core/fence.ts";
|
|
31
|
+
import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
|
|
32
|
+
import { roleHint } from "../orchestration/roster.ts";
|
|
33
|
+
import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
|
+
import type { AgentResult } from "../orchestration/types.ts";
|
|
35
|
+
import { looksLikeProviderError } from "./errors.ts";
|
|
36
|
+
import { nextChildHandle } from "./handles.ts";
|
|
37
|
+
import { combineSignals } from "./signals.ts";
|
|
38
|
+
import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
39
|
+
import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
|
|
40
|
+
|
|
41
|
+
/** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
|
|
42
|
+
export interface InProcSession {
|
|
43
|
+
subscribe(listener: (event: unknown) => void): () => void;
|
|
44
|
+
prompt(input: string): Promise<void>;
|
|
45
|
+
agent: { abort(): void; waitForIdle(): Promise<void>; steer(message: unknown): void };
|
|
46
|
+
dispose(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CreateSessionOptions {
|
|
50
|
+
model: unknown;
|
|
51
|
+
modelRegistry: ModelRegistry;
|
|
52
|
+
/** Cooperative cancellation for resource/session initialization. Pi's loader does not
|
|
53
|
+
* currently accept a signal itself, but injected factories can stop promptly and the
|
|
54
|
+
* default factory checks it before/after each uncancellable Pi operation. */
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
/** The host's canonical runtime (Pi >=0.81); shared so auth/provider registrations survive. */
|
|
57
|
+
modelRuntime?: unknown;
|
|
58
|
+
cwd: string;
|
|
59
|
+
/** Pi's global agent dir (~/.pi/agent) for the sub-agent's resource loader. */
|
|
60
|
+
agentDir: string;
|
|
61
|
+
tools?: string[];
|
|
62
|
+
excludeTools?: string[];
|
|
63
|
+
thinkingLevel?: ThinkingLevel;
|
|
64
|
+
/** The agent's persona (its .md) — appended to the base system prompt. */
|
|
65
|
+
systemPrompt?: string;
|
|
66
|
+
/** Extra tools injected into the child session (e.g. `contact_supervisor`). */
|
|
67
|
+
customTools?: ToolDefinition[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CreateInProcSession = (opts: CreateSessionOptions) => Promise<InProcSession>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pi 0.81 moved the canonical auth/provider state from the extension-facing
|
|
74
|
+
* ModelRegistry to a ModelRuntime passed directly to createAgentSession. The
|
|
75
|
+
* compatibility facade still owns that runtime, but intentionally keeps it
|
|
76
|
+
* private in its public TypeScript API. Read the runtime only when the expected
|
|
77
|
+
* methods are present; old Pi versions and test facades then retain the legacy
|
|
78
|
+
* modelRegistry path.
|
|
79
|
+
*/
|
|
80
|
+
function sharedModelRuntime(registry: ModelRegistry): unknown {
|
|
81
|
+
const candidate = (registry as unknown as { runtime?: unknown }).runtime;
|
|
82
|
+
if (!candidate || typeof candidate !== "object") return undefined;
|
|
83
|
+
const runtime = candidate as { getAuth?: unknown; getModel?: unknown; stream?: unknown };
|
|
84
|
+
return typeof runtime.getAuth === "function" && typeof runtime.getModel === "function" && typeof runtime.stream === "function"
|
|
85
|
+
? candidate
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface InProcessDeps {
|
|
90
|
+
resolveAgent: (name: string) => AgentConfig | undefined;
|
|
91
|
+
contracts?: (name: string) => ContractDef | undefined;
|
|
92
|
+
/** The host session's registry — shared so the sub-agent reuses the loader's auth. */
|
|
93
|
+
modelRegistry: ModelRegistry;
|
|
94
|
+
cwd: string;
|
|
95
|
+
/** Pi's global agent dir (~/.pi/agent); defaults to cwd if omitted (tests). */
|
|
96
|
+
agentDir?: string;
|
|
97
|
+
/** Per-agent model override (e.g. a persona's configured ensemble models). */
|
|
98
|
+
modelFor?: (agent: string) => string | undefined;
|
|
99
|
+
/** The session/loader model — the last-resort fallback when an agent declares none
|
|
100
|
+
* (the child engine gets this for free from `pi -p`'s default; inproc must pass it). */
|
|
101
|
+
defaultModel?: string;
|
|
102
|
+
/** Explicit thinking level for the sub-agent (the supervisor's, or a default). */
|
|
103
|
+
childThinking?: string;
|
|
104
|
+
/** The shared behavioral layer (docs/SPINE.md), prepended to every leg's prompt. This is the
|
|
105
|
+
* WORKER variant (`prompts/spine.worker.md`), not the supervisor text — a leg runs headless
|
|
106
|
+
* with no user to confirm anything with. Absent/empty (the default) ⇒ the composed prompt is
|
|
107
|
+
* byte-identical to the pre-spine one. */
|
|
108
|
+
spine?: string;
|
|
109
|
+
/** Whole-run abort signal (combined with each call's own signal). */
|
|
110
|
+
signal?: AbortSignal;
|
|
111
|
+
/** Construction-time progress sink (used when `run` is called without its own). */
|
|
112
|
+
onProgress?: (snap: ProgressSnapshot) => void;
|
|
113
|
+
/** Known agent names, for self-correcting unknown-agent errors (mirrors the model path). */
|
|
114
|
+
listAgents?: () => string[];
|
|
115
|
+
/** Installed output-contract names (built-in `default` + files) for the self-correcting hint. */
|
|
116
|
+
listContracts?: () => string[];
|
|
117
|
+
/** Session factory — defaults to a real `createAgentSession`; injected in tests. */
|
|
118
|
+
createSession?: CreateInProcSession;
|
|
119
|
+
/** The semantic comm plane: when present + `coaching`, each child gets a
|
|
120
|
+
* `contact_supervisor` tool bound to this bus so it can reach the supervisor mid-run. */
|
|
121
|
+
bus?: InProcessBus;
|
|
122
|
+
/** The supervisor's bus handle (default `"supervisor"`). */
|
|
123
|
+
supervisorHandle?: string;
|
|
124
|
+
/** Inject `contact_supervisor` into children (persona `coaching: on`). Default off. */
|
|
125
|
+
coaching?: boolean;
|
|
126
|
+
/** Allow a child's `decision`/`interview` to BLOCK for a reply — async runs only.
|
|
127
|
+
* Sync runs hold the supervisor's turn, so blocking would deadlock: keep this false. */
|
|
128
|
+
allowBlocking?: boolean;
|
|
129
|
+
/** Persona-level bus capability (`EffectiveCapabilities.canUseBus`). Default true.
|
|
130
|
+
* When false, a spec's `peers` request is ignored (no `contact_peer` tool is bound). */
|
|
131
|
+
canUseBus?: boolean;
|
|
132
|
+
/** IDLE window (ms): a session that emits NO events for this long is treated as hung
|
|
133
|
+
* and aborted (mirrors the child engine's idle kill). 0/absent = no watchdog. Stays armed
|
|
134
|
+
* for every child, coaching or not: when it fires, the bus is asked whether THIS child has
|
|
135
|
+
* a live ask outstanding (`bus.hasPendingAskFrom`) — true re-arms the window instead of
|
|
136
|
+
* killing, false means the silence is real (R9). */
|
|
137
|
+
timeoutMs?: number;
|
|
138
|
+
/** HARD wall-clock cap (ms): a definite lifetime ceiling, armed ONCE and never reset by
|
|
139
|
+
* events — so it catches a busy-but-non-converging child (a loop that keeps emitting) that
|
|
140
|
+
* the idle watchdog, which any event re-arms, never would. 0/absent = no cap. Applies
|
|
141
|
+
* unconditionally, including to a coaching child legitimately blocked on a reply. */
|
|
142
|
+
hardTimeoutMs?: number;
|
|
143
|
+
/** STARTUP deadline (ms): a session that never makes PROGRESS (no completed turn, no tokens,
|
|
144
|
+
* no streamed output) within this window is aborted as a stalled start — the case the idle
|
|
145
|
+
* window is too generous for. The FIRST progress cancels it permanently, so a
|
|
146
|
+
* slow-but-streaming turn is never touched. 0/absent = no startup deadline. Stays armed for
|
|
147
|
+
* every child, same bus consult as the idle window above before it actually cancels (R9).
|
|
148
|
+
*
|
|
149
|
+
* CHOOSE THE VALUE FOR THE FIRST PROVIDER ROUND TRIP, not for init alone. Only the child's
|
|
150
|
+
* OWN assistant output counts as progress — pi emits message_start/message_end for the
|
|
151
|
+
* prompt it just delivered too, and stream.ts filters those out — so the window has to
|
|
152
|
+
* cover the whole way to the FIRST provider response. A queued or rate-limited provider, a
|
|
153
|
+
* cold serverless endpoint or a local model doing a long prompt eval can spend minutes
|
|
154
|
+
* there, and this deadline aborts such a leg outright (failureKind "timeout"); the idle
|
|
155
|
+
* watchdog would not, because any event at all re-arms it. */
|
|
156
|
+
startupTimeoutMs?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The sub-agent must never re-enter the supervisor's orchestration surface. This is the SECOND
|
|
160
|
+
// line of defense (the first is that a child's whole pi-persona activation short-circuits under
|
|
161
|
+
// PI_PERSONA_DISABLE=1 and registers no tools at all, on either engine — see I2 in
|
|
162
|
+
// docs/ARCHITECTURE.md); nesting depth is capped at 1 structurally, not by a numeric run limit.
|
|
163
|
+
export const ORCHESTRATION_TOOLS = ["delegate", "council", "orchestrate", "flow"];
|
|
164
|
+
|
|
165
|
+
/** Union a child session's excludeTools with `ORCHESTRATION_TOOLS`, so a delegated leg has no
|
|
166
|
+
* tool call that could start a grandchild, however it is asked to — regardless of what the
|
|
167
|
+
* agent's own config additionally excludes. */
|
|
168
|
+
export function withOrchestrationToolsExcluded(extra: readonly string[] | undefined): string[] {
|
|
169
|
+
return [...new Set([...ORCHESTRATION_TOOLS, ...(extra ?? [])])];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Preserve the semantic difference between an absent allowlist (Pi defaults) and an explicit
|
|
173
|
+
* empty one (no tools). Custom communication tools join only a non-empty allowlist; an empty
|
|
174
|
+
* grant must not quietly grow capabilities during session construction. */
|
|
175
|
+
export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
|
|
176
|
+
if (tools === undefined) return undefined;
|
|
177
|
+
if (tools.length === 0) return [];
|
|
178
|
+
return [...new Set([...tools, ...customNames])];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Default factory: a real in-process Pi session. The resource loader appends the
|
|
182
|
+
* agent's persona to the base system prompt (the in-process `--append-system-prompt`).
|
|
183
|
+
* Extensions DO load (so auth providers like pi-claude work) — the fork-bomb guard is
|
|
184
|
+
* that pi-persona self-disables under `PI_PERSONA_DISABLE` (set around creation), with
|
|
185
|
+
* the orchestration tools excluded as a second line of defense. */
|
|
186
|
+
const createPiSession: CreateInProcSession = async (opts) => {
|
|
187
|
+
if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
|
|
188
|
+
const loader = new DefaultResourceLoader({
|
|
189
|
+
cwd: opts.cwd,
|
|
190
|
+
agentDir: opts.agentDir,
|
|
191
|
+
...(opts.systemPrompt ? { appendSystemPrompt: [opts.systemPrompt] } : {}),
|
|
192
|
+
});
|
|
193
|
+
await loader.reload();
|
|
194
|
+
if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
|
|
195
|
+
// If a `tools` allowlist is set, the injected custom tools must be allowed too,
|
|
196
|
+
// otherwise the allowlist would filter them out of the child's active set.
|
|
197
|
+
const customNames = (opts.customTools ?? []).map((t) => t.name);
|
|
198
|
+
const tools = mergeSessionToolAllowlist(opts.tools, customNames);
|
|
199
|
+
// Forward-compat with the model-runtime migration (pi > 0.80.7). pi <= 0.80.7 reads
|
|
200
|
+
// `CreateAgentSessionOptions.modelRegistry` to share the host's auth/model registry with the
|
|
201
|
+
// sub-session; newer pi takes the canonical `modelRuntime` instead. The extension-facing
|
|
202
|
+
// ModelRegistry keeps that runtime private in its type surface, so `sharedModelRuntime()` above
|
|
203
|
+
// recovers it only when the runtime shape is present. Passing it is important even when auth is
|
|
204
|
+
// on disk: native providers and runtime credentials can exist only in the supervisor's memory.
|
|
205
|
+
// `agentDir` remains a valid fallback for older/standalone SDKs, and the legacy key stays outside
|
|
206
|
+
// the object literal so current SDK types do not reject it.
|
|
207
|
+
const sessionOptions: NonNullable<Parameters<typeof createAgentSession>[0]> = {
|
|
208
|
+
model: opts.model as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>,
|
|
209
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
210
|
+
resourceLoader: loader,
|
|
211
|
+
cwd: opts.cwd,
|
|
212
|
+
agentDir: opts.agentDir,
|
|
213
|
+
...(opts.modelRuntime !== undefined
|
|
214
|
+
? { modelRuntime: opts.modelRuntime as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["modelRuntime"]> }
|
|
215
|
+
: {}),
|
|
216
|
+
excludeTools: withOrchestrationToolsExcluded(opts.excludeTools),
|
|
217
|
+
...(tools !== undefined ? { tools } : {}),
|
|
218
|
+
...(opts.customTools && opts.customTools.length > 0 ? { customTools: opts.customTools } : {}),
|
|
219
|
+
// `opts.thinkingLevel` is our local ThinkingLevel superset — cast to pi's field type at the
|
|
220
|
+
// boundary (same rationale as PersonaHost.setThinkingLevel: a `max`-style level only reaches
|
|
221
|
+
// here on a pi that supports it, and pi clamps an unknown level regardless).
|
|
222
|
+
...(opts.thinkingLevel
|
|
223
|
+
? { thinkingLevel: opts.thinkingLevel as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]> }
|
|
224
|
+
: {}),
|
|
225
|
+
};
|
|
226
|
+
(sessionOptions as { modelRegistry?: unknown }).modelRegistry = opts.modelRegistry;
|
|
227
|
+
const { session } = await createAgentSession(sessionOptions);
|
|
228
|
+
if (opts.signal?.aborted) {
|
|
229
|
+
try {
|
|
230
|
+
session.dispose();
|
|
231
|
+
} catch {
|
|
232
|
+
// The initialization cancellation result still owns the lifecycle if disposal throws.
|
|
233
|
+
}
|
|
234
|
+
throw new DOMException("Session initialization aborted", "AbortError");
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
subscribe: (l) => session.subscribe(l as Parameters<typeof session.subscribe>[0]),
|
|
238
|
+
prompt: async (input) => {
|
|
239
|
+
await session.prompt(input);
|
|
240
|
+
},
|
|
241
|
+
agent: {
|
|
242
|
+
abort: () => session.agent.abort(),
|
|
243
|
+
waitForIdle: () => session.agent.waitForIdle(),
|
|
244
|
+
steer: (message) => session.agent.steer(message as Parameters<typeof session.agent.steer>[0]),
|
|
245
|
+
},
|
|
246
|
+
dispose: () => session.dispose(),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Resolve a model ref (`provider/id`, or a bare id) to a registry Model. */
|
|
251
|
+
function resolveModel(reg: ModelRegistry, ref: string | undefined): unknown {
|
|
252
|
+
if (!ref) return undefined;
|
|
253
|
+
const slash = ref.indexOf("/");
|
|
254
|
+
if (slash > 0) {
|
|
255
|
+
const m = reg.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
256
|
+
if (m) return m;
|
|
257
|
+
}
|
|
258
|
+
return reg.getAll().find((m) => m.id === ref);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Ref-counted fork-bomb guard: PI_PERSONA_DISABLE stays "1" while ANY sub-session is being
|
|
262
|
+
// built (a parallel strategy builds several at once), restored only when the LAST one finishes.
|
|
263
|
+
// A per-call save/restore around the async `createSession` races: it can clear the guard
|
|
264
|
+
// mid-build (fork-bomb window) or leave it stuck. Increment/decrement never cross the await.
|
|
265
|
+
//
|
|
266
|
+
// PI_PERSONA_LEG rides alongside it as a DEDICATED "this session is a delegated worker leg" marker.
|
|
267
|
+
// PI_PERSONA_DISABLE alone is ambiguous — a user can set it as a kill switch — so a companion
|
|
268
|
+
// extension (e.g. pi-persona-mind) cannot tell a real leg from a disabled supervisor by DISABLE
|
|
269
|
+
// alone. LEG is set ONLY here (and in the child engine), never by a user, so it names a leg exactly.
|
|
270
|
+
let disableDepth = 0;
|
|
271
|
+
let savedDisable: string | undefined;
|
|
272
|
+
let savedLeg: string | undefined;
|
|
273
|
+
function pushDisableGuard(): void {
|
|
274
|
+
if (disableDepth === 0) {
|
|
275
|
+
savedDisable = process.env.PI_PERSONA_DISABLE;
|
|
276
|
+
savedLeg = process.env.PI_PERSONA_LEG;
|
|
277
|
+
}
|
|
278
|
+
disableDepth += 1;
|
|
279
|
+
process.env.PI_PERSONA_DISABLE = "1";
|
|
280
|
+
process.env.PI_PERSONA_LEG = "1";
|
|
281
|
+
}
|
|
282
|
+
function popDisableGuard(): void {
|
|
283
|
+
disableDepth -= 1;
|
|
284
|
+
if (disableDepth === 0) {
|
|
285
|
+
if (savedDisable === undefined) delete process.env.PI_PERSONA_DISABLE;
|
|
286
|
+
else process.env.PI_PERSONA_DISABLE = savedDisable;
|
|
287
|
+
if (savedLeg === undefined) delete process.env.PI_PERSONA_LEG;
|
|
288
|
+
else process.env.PI_PERSONA_LEG = savedLeg;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
293
|
+
const createSession = deps.createSession ?? createPiSession;
|
|
294
|
+
const supervisorHandle = deps.supervisorHandle ?? "supervisor";
|
|
295
|
+
// Per-run contract pinning (I3), mirroring the child adapter.
|
|
296
|
+
const pinned = new Map<string, PinnedContract>();
|
|
297
|
+
const pinnedDef = (name: string): ContractDef | undefined => {
|
|
298
|
+
const cached = pinned.get(name);
|
|
299
|
+
if (cached) return cached.def;
|
|
300
|
+
const def = deps.contracts?.(name);
|
|
301
|
+
if (!def) return undefined;
|
|
302
|
+
const fresh = pinContract(def);
|
|
303
|
+
pinned.set(name, fresh);
|
|
304
|
+
return fresh.def;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Peer registry — PER ENGINE INSTANCE. buildEngine makes a fresh engine per
|
|
308
|
+
// delegate/council/flow-phase/async-launch, so this map naturally scopes `contact_peer`
|
|
309
|
+
// to the members of ONE run: concurrent runs on the same bus never see each other.
|
|
310
|
+
const peerLabels = new Map<string, string>();
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
|
|
314
|
+
const cfg = deps.resolveAgent(spec.agent);
|
|
315
|
+
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
316
|
+
// A requested contract is a runtime guard, not merely prompt decoration. Resolve it
|
|
317
|
+
// before model/session construction so a missing name can never run unconstrained.
|
|
318
|
+
const requestedContract = spec.outputContract?.trim();
|
|
319
|
+
const requestedDef = requestedContract ? pinnedDef(requestedContract) : undefined;
|
|
320
|
+
if (requestedContract && !requestedDef) return unknownContractFailure(spec.agent, requestedContract, deps.listContracts?.() ?? []);
|
|
321
|
+
|
|
322
|
+
const ref = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model ?? deps.defaultModel;
|
|
323
|
+
const model = resolveModel(deps.modelRegistry, ref);
|
|
324
|
+
if (!model) {
|
|
325
|
+
const src = spec.model ? "spec" : deps.modelFor?.(spec.agent) ? "agent picker" : cfg.model ? "agent config" : "default";
|
|
326
|
+
// Make the failure self-correcting: name a few real registry refs (nearest
|
|
327
|
+
// matches first) so the supervisor's retry can pick a valid model at once.
|
|
328
|
+
const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
|
|
329
|
+
const frag = (ref ?? "").split("/").pop()?.split(":")[0]?.toLowerCase() ?? "";
|
|
330
|
+
const near = frag ? all.filter((r) => r.toLowerCase().includes(frag)) : [];
|
|
331
|
+
const hint = (near.length > 0 ? near : all).slice(0, 5);
|
|
332
|
+
return {
|
|
333
|
+
agent: spec.agent,
|
|
334
|
+
output: "",
|
|
335
|
+
usage: emptyUsage(),
|
|
336
|
+
ok: false,
|
|
337
|
+
error: `[${spec.agent} · ${ref ?? "(no model)"}] model not found in registry (from ${src})${hint.length > 0 ? ` — try e.g.: ${hint.join(", ")}` : ""}`,
|
|
338
|
+
failureKind: "unknown-model",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// The canonical provider/id this run resolved to — surfaced on every result (for the
|
|
342
|
+
// UI/cost) and, on a provider failure, used as the seed of the fallback chain.
|
|
343
|
+
const rm = model as { provider?: string; id?: string };
|
|
344
|
+
const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
|
|
345
|
+
const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
|
|
346
|
+
const tools = spec.tools ?? cfg.tools;
|
|
347
|
+
const excludeTools = cfg.excludeTools;
|
|
348
|
+
|
|
349
|
+
// Diagnostic tag: which agent + which model ref + whether it was dynamically
|
|
350
|
+
// specialised (skills / model / tools override on the spec). Users need this
|
|
351
|
+
// to tell "the delegate typo'd the model" from "the provider rejected it".
|
|
352
|
+
const overrides: string[] = [];
|
|
353
|
+
if (spec.model) overrides.push("model");
|
|
354
|
+
if (spec.skills && spec.skills.length > 0) overrides.push("skills");
|
|
355
|
+
if (spec.tools !== undefined) overrides.push("tools");
|
|
356
|
+
const dyn = overrides.length > 0 ? ` +dyn(${overrides.join(",")})` : "";
|
|
357
|
+
const tag = `[${spec.agent} · ${ref ?? "(no model)"}${dyn}]`;
|
|
358
|
+
|
|
359
|
+
const state = createStreamState();
|
|
360
|
+
const signal = combineSignals(deps.signal, callSignal);
|
|
361
|
+
|
|
362
|
+
// Already cancelled (e.g. a queued async run stopped before its slot came up):
|
|
363
|
+
// don't build a session or burn a model call — settle as aborted right away.
|
|
364
|
+
if (signal?.aborted) {
|
|
365
|
+
return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), failureKind: "abort" };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const modelRuntime = sharedModelRuntime(deps.modelRegistry);
|
|
369
|
+
const sessionOpts: CreateSessionOptions = {
|
|
370
|
+
model,
|
|
371
|
+
modelRegistry: deps.modelRegistry,
|
|
372
|
+
...(modelRuntime !== undefined ? { modelRuntime } : {}),
|
|
373
|
+
cwd: deps.cwd,
|
|
374
|
+
agentDir: deps.agentDir ?? deps.cwd,
|
|
375
|
+
};
|
|
376
|
+
if (tools !== undefined) sessionOpts.tools = tools;
|
|
377
|
+
if (excludeTools && excludeTools.length > 0) sessionOpts.excludeTools = excludeTools;
|
|
378
|
+
if (thinkingLevel) sessionOpts.thinkingLevel = thinkingLevel;
|
|
379
|
+
// The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
|
|
380
|
+
// on-the-fly `role` specialisation from the spec. All three reach the session as the
|
|
381
|
+
// loader's `appendSystemPrompt`, so a leg keeps Pi's full base prompt either way — the
|
|
382
|
+
// layer adds behavioral consistency, it does not fill a scaffolding gap.
|
|
383
|
+
const layer = cfg.spine === false ? undefined : deps.spine?.trim();
|
|
384
|
+
const personaPrompt = [layer, cfg.systemPrompt?.trim(), assignedIdentityPrompt(spec.name), spec.role?.trim()].filter(Boolean).join("\n\n");
|
|
385
|
+
if (personaPrompt) sessionOpts.systemPrompt = personaPrompt;
|
|
386
|
+
|
|
387
|
+
// Comm plane: give this child a `contact_supervisor` tool bound to a unique handle
|
|
388
|
+
// (persona coaching, §4.9), and/or a `contact_peer` tool when the STRATEGY opted the
|
|
389
|
+
// run into sibling messaging (`spec.peers`, gated by the persona's `canUseBus`).
|
|
390
|
+
let childHandle: string | undefined;
|
|
391
|
+
const wantsPeers = spec.peers === true && (deps.canUseBus ?? true) && deps.bus !== undefined;
|
|
392
|
+
if (spec.peers === true && !wantsPeers && process.env.PI_PERSONA_DEBUG) {
|
|
393
|
+
process.stderr.write(`[pi-persona] peers requested for ${spec.agent} but bus/capability unavailable — running without contact_peer\n`);
|
|
394
|
+
}
|
|
395
|
+
if (deps.bus && (deps.coaching || wantsPeers)) {
|
|
396
|
+
childHandle = nextChildHandle(spec.agent);
|
|
397
|
+
deps.bus.register(supervisorHandle);
|
|
398
|
+
deps.bus.register(childHandle);
|
|
399
|
+
const customTools: ToolDefinition[] = [];
|
|
400
|
+
if (deps.coaching) {
|
|
401
|
+
customTools.push(
|
|
402
|
+
makeContactSupervisorTool(deps.bus, childHandle, supervisorHandle, { allowBlocking: deps.allowBlocking ?? false }),
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (wantsPeers) {
|
|
406
|
+
const label = spec.name?.trim()
|
|
407
|
+
? sanitizeDisplayLabel(spec.name, childHandle)
|
|
408
|
+
: spec.role ? `${childHandle} (${roleHint(spec.role)})` : childHandle;
|
|
409
|
+
peerLabels.set(childHandle, label);
|
|
410
|
+
const self = childHandle;
|
|
411
|
+
customTools.push(
|
|
412
|
+
makeContactPeerTool(deps.bus, self, {
|
|
413
|
+
listPeers: () => [...peerLabels.entries()].filter(([h]) => h !== self).map(([h, l]) => ({ handle: h, label: l })),
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
sessionOpts.customTools = customTools;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// The handle is live on the SESSION-LONG bus from here on, while the only unregister sits
|
|
421
|
+
// in the run's own finally below. Anything that throws before we reach it must release
|
|
422
|
+
// it, or a phantom peer haunts `intercom list` (collecting dead letters nothing drains)
|
|
423
|
+
// for the rest of the supervisor's session.
|
|
424
|
+
let handleReleased = false;
|
|
425
|
+
const releaseHandle = (): void => {
|
|
426
|
+
if (!childHandle || handleReleased) return;
|
|
427
|
+
handleReleased = true;
|
|
428
|
+
peerLabels.delete(childHandle);
|
|
429
|
+
deps.bus?.unregister(childHandle);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// `agent.abort()` is a cancellation REQUEST, not a settlement guarantee: a provider/tool
|
|
433
|
+
// can leave both `prompt()` and `waitForIdle()` pending after accepting it. Keep a separate
|
|
434
|
+
// gate that every abort cause trips, so the engine always reaches its finally-cleanup instead
|
|
435
|
+
// of retaining the session (and its async semaphore slot) forever. The same gate also settles
|
|
436
|
+
// an initialization that cannot be interrupted by Pi's current resource-loader API.
|
|
437
|
+
let releaseAbortGate: () => void = () => {};
|
|
438
|
+
const abortGate = new Promise<void>((resolve) => {
|
|
439
|
+
releaseAbortGate = resolve;
|
|
440
|
+
});
|
|
441
|
+
let session!: InProcSession;
|
|
442
|
+
let initializedSession: InProcSession | undefined;
|
|
443
|
+
let aborted = false;
|
|
444
|
+
let timedOut = false;
|
|
445
|
+
let hardTimedOut = false;
|
|
446
|
+
let startupTimedOut = false;
|
|
447
|
+
let cancellationRequested = false;
|
|
448
|
+
const initController = new AbortController();
|
|
449
|
+
const abortSession = (): void => {
|
|
450
|
+
const target = initializedSession;
|
|
451
|
+
try {
|
|
452
|
+
target?.agent.abort();
|
|
453
|
+
} catch {
|
|
454
|
+
// Settlement and dispose must still run when the host abort hook itself throws.
|
|
455
|
+
} finally {
|
|
456
|
+
releaseAbortGate();
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const requestCancellation = (kind: "abort" | "idle" | "startup" | "hard"): void => {
|
|
460
|
+
// Host abort hooks can synchronously emit more events or cancellation signals.
|
|
461
|
+
// Preserve the first cause and invoke the host abort hook only once.
|
|
462
|
+
if (cancellationRequested) return;
|
|
463
|
+
cancellationRequested = true;
|
|
464
|
+
if (kind === "abort") aborted = true;
|
|
465
|
+
else if (kind === "idle") timedOut = true;
|
|
466
|
+
else if (kind === "hard") hardTimedOut = true;
|
|
467
|
+
else startupTimedOut = true;
|
|
468
|
+
if (!initController.signal.aborted) initController.abort();
|
|
469
|
+
abortSession();
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// Idle watchdog (mirrors the child engine's idle kill): a session that emits no
|
|
473
|
+
// events for `timeoutMs` is hung (e.g. a stuck provider stream) — without this the
|
|
474
|
+
// DEFAULT engine would await `waitForIdle()` forever and the run would never settle.
|
|
475
|
+
// Any event re-arms the clock, so a long-but-active agent is never killed. Stays armed
|
|
476
|
+
// for every child, coaching or not: when it fires, the bus is consulted first (R9) —
|
|
477
|
+
// a live pending ask from THIS child re-arms the window instead of killing.
|
|
478
|
+
const specTimeoutMs = isPositiveFiniteMs(spec.timeoutMs) ? spec.timeoutMs : undefined;
|
|
479
|
+
const watchdogMs = specTimeoutMs ?? (isPositiveFiniteMs(deps.timeoutMs) ? deps.timeoutMs : 0);
|
|
480
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
481
|
+
const disarmIdle = (): void => {
|
|
482
|
+
if (idleTimer) {
|
|
483
|
+
clearTimeout(idleTimer);
|
|
484
|
+
idleTimer = undefined;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const armIdle = (): void => {
|
|
488
|
+
disarmIdle();
|
|
489
|
+
if (watchdogMs <= 0 || cancellationRequested) return;
|
|
490
|
+
idleTimer = setTimeout(() => {
|
|
491
|
+
if (childHandle && deps.bus?.hasPendingAskFrom(childHandle)) {
|
|
492
|
+
armIdle();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
requestCancellation("idle");
|
|
496
|
+
}, watchdogMs);
|
|
497
|
+
idleTimer.unref?.();
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Hard wall-clock cap: armed ONCE, never reset by events — a definite lifetime ceiling
|
|
501
|
+
// that settles a busy-but-non-converging child (the idle watchdog above never catches).
|
|
502
|
+
// It starts before session construction, matching the child engine's process lifetime cap.
|
|
503
|
+
// Applies unconditionally — including to a coaching child legitimately blocked on a reply.
|
|
504
|
+
const hardMs = isPositiveFiniteMs(deps.hardTimeoutMs) ? deps.hardTimeoutMs : 0;
|
|
505
|
+
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
506
|
+
const armHard = (): void => {
|
|
507
|
+
if (hardMs <= 0 || hardTimer) return;
|
|
508
|
+
hardTimer = setTimeout(() => requestCancellation("hard"), hardMs);
|
|
509
|
+
hardTimer.unref?.();
|
|
510
|
+
};
|
|
511
|
+
const disarmHard = (): void => {
|
|
512
|
+
if (hardTimer) {
|
|
513
|
+
clearTimeout(hardTimer);
|
|
514
|
+
hardTimer = undefined;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// Startup deadline (mirrors the child engine): one window from construction to the first
|
|
519
|
+
// real progress (completed turn / tokens / streamed output). Stays armed for every child,
|
|
520
|
+
// including one that may block on a supervisor reply before ever emitting anything — the
|
|
521
|
+
// bus consult below re-arms it for as long as that ask is genuinely pending (R9). During
|
|
522
|
+
// construction there is no session yet, so no ask can possibly be pending: the window
|
|
523
|
+
// behaves exactly as it always has while initializing.
|
|
524
|
+
const startupMs = isPositiveFiniteMs(deps.startupTimeoutMs) ? deps.startupTimeoutMs : 0;
|
|
525
|
+
let startupProgressed = false;
|
|
526
|
+
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
|
527
|
+
const disarmStartup = (): void => {
|
|
528
|
+
if (startupTimer) {
|
|
529
|
+
clearTimeout(startupTimer);
|
|
530
|
+
startupTimer = undefined;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const armStartup = (): void => {
|
|
534
|
+
disarmStartup();
|
|
535
|
+
if (startupMs <= 0) return;
|
|
536
|
+
startupTimer = setTimeout(() => {
|
|
537
|
+
if (startupProgressed) return;
|
|
538
|
+
if (childHandle && deps.bus?.hasPendingAskFrom(childHandle)) {
|
|
539
|
+
armStartup();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
requestCancellation("startup");
|
|
543
|
+
}, startupMs);
|
|
544
|
+
startupTimer.unref?.();
|
|
545
|
+
};
|
|
546
|
+
const noteStartupProgress = (): void => {
|
|
547
|
+
if (startupProgressed) return;
|
|
548
|
+
const s = snapshot(state);
|
|
549
|
+
if (s.turns > 0 || s.tokens > 0 || s.output.length > 0) {
|
|
550
|
+
startupProgressed = true;
|
|
551
|
+
disarmStartup();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// The Pi resource loader currently has no cancellation API. `armIdle`/`armHard`/`armStartup`
|
|
556
|
+
// (below) already start before the factory is invoked and stay held until the factory
|
|
557
|
+
// promise itself settles, so a timed-out run may return to its semaphore caller while a
|
|
558
|
+
// late session is disposed and a late rejection is consumed by this handled promise.
|
|
559
|
+
const onAbort = (): void => requestCancellation("abort");
|
|
560
|
+
if (signal) {
|
|
561
|
+
if (signal.aborted) requestCancellation("abort");
|
|
562
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
563
|
+
}
|
|
564
|
+
if (cancellationRequested) {
|
|
565
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
566
|
+
releaseHandle();
|
|
567
|
+
return {
|
|
568
|
+
agent: spec.agent,
|
|
569
|
+
output: "",
|
|
570
|
+
usage: emptyUsage(),
|
|
571
|
+
ok: false,
|
|
572
|
+
error: `${tag} agent aborted`,
|
|
573
|
+
...(resolvedRef ? { modelUsed: resolvedRef } : {}),
|
|
574
|
+
failureKind: "abort",
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const safeDispose = (late: InProcSession): void => {
|
|
579
|
+
try {
|
|
580
|
+
late.dispose();
|
|
581
|
+
} catch {
|
|
582
|
+
// A late session has no consumer left to report disposal errors to; never turn
|
|
583
|
+
// cleanup into an unhandled rejection or strand the global guard.
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
type InitializationOutcome = { session?: InProcSession; error?: unknown };
|
|
587
|
+
let initializationSettled = false;
|
|
588
|
+
|
|
589
|
+
pushDisableGuard();
|
|
590
|
+
armHard();
|
|
591
|
+
armIdle();
|
|
592
|
+
armStartup();
|
|
593
|
+
const initialization = Promise.resolve()
|
|
594
|
+
.then(() => createSession({ ...sessionOpts, signal: initController.signal }))
|
|
595
|
+
.then(
|
|
596
|
+
(created) => {
|
|
597
|
+
if (cancellationRequested) {
|
|
598
|
+
safeDispose(created);
|
|
599
|
+
return {};
|
|
600
|
+
}
|
|
601
|
+
initializedSession = created;
|
|
602
|
+
return { session: created };
|
|
603
|
+
},
|
|
604
|
+
(error): InitializationOutcome => ({ error }),
|
|
605
|
+
)
|
|
606
|
+
.then((outcome) => {
|
|
607
|
+
initializationSettled = true;
|
|
608
|
+
// Do not pop this guard at the timeout boundary: the original factory may still
|
|
609
|
+
// be loading recursive extensions under PI_PERSONA_DISABLE=1.
|
|
610
|
+
popDisableGuard();
|
|
611
|
+
return outcome;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const initializationOutcome = await Promise.race([
|
|
615
|
+
initialization,
|
|
616
|
+
abortGate.then(() => undefined as InitializationOutcome | undefined),
|
|
617
|
+
]);
|
|
618
|
+
if (cancellationRequested || !initializationOutcome || !initializationOutcome.session) {
|
|
619
|
+
if (cancellationRequested && initializedSession) {
|
|
620
|
+
const cancelledSession = initializedSession;
|
|
621
|
+
initializedSession = undefined;
|
|
622
|
+
safeDispose(cancelledSession);
|
|
623
|
+
}
|
|
624
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
625
|
+
disarmIdle();
|
|
626
|
+
disarmHard();
|
|
627
|
+
disarmStartup();
|
|
628
|
+
releaseHandle();
|
|
629
|
+
if (initializationOutcome && "error" in initializationOutcome && !cancellationRequested) throw initializationOutcome.error;
|
|
630
|
+
const initError = hardTimedOut
|
|
631
|
+
? `${tag} agent exceeded the ${hardMs}ms hard cap during session initialization`
|
|
632
|
+
: timedOut
|
|
633
|
+
? `${tag} agent timed out during session initialization — no events for ${watchdogMs}ms`
|
|
634
|
+
: startupTimedOut
|
|
635
|
+
? `${tag} agent produced no output within the ${startupMs}ms startup window while initializing the session`
|
|
636
|
+
: `${tag} agent aborted`;
|
|
637
|
+
const cleanupHint = initializationSettled ? "" : "; initialization cancellation requested, but the host loader may still be unwinding. If initialization keeps hanging, restart Pi and use PI_PERSONA_ENGINE=child";
|
|
638
|
+
return {
|
|
639
|
+
agent: spec.agent,
|
|
640
|
+
output: "",
|
|
641
|
+
usage: emptyUsage(),
|
|
642
|
+
ok: false,
|
|
643
|
+
error: `${initError}${cleanupHint}`,
|
|
644
|
+
...(resolvedRef ? { modelUsed: resolvedRef } : {}),
|
|
645
|
+
failureKind: hardTimedOut || timedOut || startupTimedOut ? "timeout" : "abort",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
session = initializationOutcome.session;
|
|
649
|
+
|
|
650
|
+
// From here to the run's own try/finally the child is live but its cleanup is not
|
|
651
|
+
// armed yet: the subscription, the caller-supplied `onSteerable`, and the delivery
|
|
652
|
+
// bridge's initial flush all run outside it. A throw in any of them must release the
|
|
653
|
+
// handle AND the session, never leave a phantom peer plus an orphaned live session.
|
|
654
|
+
let unsub: (() => void) | undefined;
|
|
655
|
+
let unsubBridge: (() => void) | undefined;
|
|
656
|
+
let steerable = true;
|
|
657
|
+
// Calls whose start crossed the progress seam but whose end has not. The tool NAME rides
|
|
658
|
+
// beside the id because the synthetic close below has to carry it, and stream.ts tracks
|
|
659
|
+
// only the ids. Mirrors the child engine's `openToolCalls`.
|
|
660
|
+
const openToolCalls = new Map<string, string>();
|
|
661
|
+
// One progress tick, routed exactly as the subscription routes them: the per-call sink
|
|
662
|
+
// takes the AgentProgress shape (no `turns`), else the construction-time sink.
|
|
663
|
+
const emitProgress = (snap: ProgressSnapshot): void => {
|
|
664
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
665
|
+
else if (deps.onProgress) deps.onProgress(snap);
|
|
666
|
+
};
|
|
667
|
+
let contractDef: ContractDef | undefined = requestedDef;
|
|
668
|
+
let task: string;
|
|
669
|
+
try {
|
|
670
|
+
unsub = session.subscribe((ev) => {
|
|
671
|
+
armIdle(); // any event = activity → reset the idle clock
|
|
672
|
+
const toolEvent = applyEvent(state, ev);
|
|
673
|
+
// Track the open calls so an abandoned one can be closed once the run settles.
|
|
674
|
+
if (toolEvent) {
|
|
675
|
+
if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
|
|
676
|
+
else openToolCalls.delete(toolEvent.callId);
|
|
677
|
+
}
|
|
678
|
+
noteStartupProgress(); // first real progress cancels the startup deadline
|
|
679
|
+
emitProgress(snapshot(state, toolEvent));
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Steering: the in-process engine can inject a user message into the running
|
|
683
|
+
// agent — the v0.4 payoff the one-shot child engine can't do.
|
|
684
|
+
onSteerable?.((text) => {
|
|
685
|
+
if (!steerable) return false;
|
|
686
|
+
const trimmed = text.trim();
|
|
687
|
+
if (!trimmed) return false;
|
|
688
|
+
try {
|
|
689
|
+
session.agent.steer({ role: "user", content: [{ type: "text", text: trimmed }] });
|
|
690
|
+
return true;
|
|
691
|
+
} catch {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Delivery bridge: bus messages addressed to this child are steered into its live
|
|
697
|
+
// session as fenced, attributed user turns — attribution stays OUTSIDE the fence so
|
|
698
|
+
// a malicious payload cannot spoof its sender. Serves sibling `contact_peer` sends
|
|
699
|
+
// AND the supervisor's `intercom send` (previously a dead letter: nothing ever
|
|
700
|
+
// drained a child inbox). Flush once on subscribe to catch a message that raced
|
|
701
|
+
// registration (the handle registers before the session finishes building).
|
|
702
|
+
if (childHandle && deps.bus) {
|
|
703
|
+
const b = deps.bus;
|
|
704
|
+
const self = childHandle;
|
|
705
|
+
const deliver = (): void => {
|
|
706
|
+
for (const env of b.takeWhere(self, (e) => !e.expectsReply)) {
|
|
707
|
+
const from = env.from === supervisorHandle ? "your supervisor" : `peer ${peerLabels.get(env.from) ?? env.from}`;
|
|
708
|
+
// deliver() runs on the initial flush (BEFORE the run's try/finally below) and
|
|
709
|
+
// inside the bus.onMessage listener (a SENDER's bus.send call stack) — an
|
|
710
|
+
// uncaught steer() throw here would leak the bus registration/observer, skip
|
|
711
|
+
// engine.run cleanup, or propagate into the sender's contact_peer call. Drop the
|
|
712
|
+
// envelope instead; it is lost, not redelivered (mirrors "gone" semantics).
|
|
713
|
+
try {
|
|
714
|
+
session.agent.steer({ role: "user", content: [{ type: "text", text: attributeInbound(from, env.text) }] });
|
|
715
|
+
} catch (e) {
|
|
716
|
+
if (process.env.PI_PERSONA_DEBUG) {
|
|
717
|
+
process.stderr.write(`[pi-persona] delivery bridge steer failed for ${self} (envelope from ${env.from} dropped): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
718
|
+
}
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
// Transparency tick: surface the delivery on the run's progress line (agent tree).
|
|
722
|
+
// Mirrors the main subscription's fallback (line ~332): per-call onProgress wins
|
|
723
|
+
// (AgentProgress — no `turns`), else the construction-time deps.onProgress
|
|
724
|
+
// (ProgressSnapshot — built from snapshot(state), `turns` included) — the async
|
|
725
|
+
// launch path (extension.ts) only has the latter.
|
|
726
|
+
const snap = snapshot(state);
|
|
727
|
+
const activity = `✉ from ${env.from}`;
|
|
728
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, activity });
|
|
729
|
+
else if (deps.onProgress) deps.onProgress({ ...snap, activity });
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
unsubBridge = b.onMessage((env) => {
|
|
733
|
+
if (env.to === self && !env.expectsReply) deliver();
|
|
734
|
+
});
|
|
735
|
+
deliver();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// The agent's persona is now the session's appended system prompt (see the
|
|
739
|
+
// resource loader); the user turn carries just the task (+ any skills to load,
|
|
740
|
+
// + the output-contract format when one is requested — resolved and pinned HERE
|
|
741
|
+
// so the def that instructs the member is the def that validates it below).
|
|
742
|
+
const skillsPreamble =
|
|
743
|
+
spec.skills && spec.skills.length > 0
|
|
744
|
+
? `Load these skills before starting (use the nearest affine if one is missing): ${spec.skills.join(", ")}.\n\n`
|
|
745
|
+
: "";
|
|
746
|
+
const contractTail = contractDef ? `\n\n${contractInstructions(contractDef)}` : "";
|
|
747
|
+
task = `${skillsPreamble}Task: ${spec.task}${contractTail}`;
|
|
748
|
+
} catch (e) {
|
|
749
|
+
disarmIdle();
|
|
750
|
+
disarmHard();
|
|
751
|
+
disarmStartup();
|
|
752
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
753
|
+
unsubBridge?.();
|
|
754
|
+
unsub?.();
|
|
755
|
+
releaseHandle();
|
|
756
|
+
steerable = false;
|
|
757
|
+
session.dispose();
|
|
758
|
+
throw e;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Capture a thrown error (e.g. provider API 400 before any stream event fires,
|
|
762
|
+
// like model_not_supported) so it survives the finally-cleanup and can be
|
|
763
|
+
// folded into the AgentResult below. Otherwise the stream state stays empty
|
|
764
|
+
// and the user sees an opaque "agent failed (unknown)".
|
|
765
|
+
let thrownError: string | undefined;
|
|
766
|
+
try {
|
|
767
|
+
armIdle(); // start the idle clock (reset on every session event)
|
|
768
|
+
armHard(); // start the lifetime ceiling (never reset)
|
|
769
|
+
const runToIdle = async (): Promise<void> => {
|
|
770
|
+
await session.prompt(task);
|
|
771
|
+
// An abort may have won while prompt() was resolving. Do not start another host wait
|
|
772
|
+
// after that point; the outer race is already taking us through cleanup.
|
|
773
|
+
if (!aborted && !timedOut && !hardTimedOut && !startupTimedOut) await session.agent.waitForIdle();
|
|
774
|
+
};
|
|
775
|
+
if (aborted || timedOut || hardTimedOut || startupTimedOut) await abortGate;
|
|
776
|
+
else await Promise.race([runToIdle(), abortGate]);
|
|
777
|
+
} catch (e) {
|
|
778
|
+
thrownError = e instanceof Error ? e.message : String(e);
|
|
779
|
+
} finally {
|
|
780
|
+
disarmIdle();
|
|
781
|
+
disarmHard();
|
|
782
|
+
disarmStartup();
|
|
783
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
784
|
+
unsubBridge?.();
|
|
785
|
+
releaseHandle();
|
|
786
|
+
unsub?.();
|
|
787
|
+
steerable = false;
|
|
788
|
+
session.dispose();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const ok =
|
|
792
|
+
!aborted && !timedOut && !hardTimedOut && !startupTimedOut && !thrownError && state.stopReason !== "error" && state.stopReason !== "aborted";
|
|
793
|
+
// A session torn down mid-flight (abort, timeout, provider throw) never delivers the
|
|
794
|
+
// tool_execution_end for whatever it was running: pi emits that end only once the tool has
|
|
795
|
+
// unwound (a macrotask or more later, e.g. bash killing its tree then awaiting close), by which
|
|
796
|
+
// time the teardown above has dropped the listener. Close each abandoned call here, as the child
|
|
797
|
+
// engine's finish() does — otherwise the consumer shows that tool "running" forever and its
|
|
798
|
+
// telemetry record stays pinned. Two rules keep the synthesis honest:
|
|
799
|
+
// * a run that SETTLED NORMALLY is left alone. pi notifies subscribers behind the agent
|
|
800
|
+
// (_handleAgentEvent awaits the extension runner before it fans out), so a green run's last
|
|
801
|
+
// end can land after waitForIdle() resolved — closing it `failed` would report a tool that
|
|
802
|
+
// actually completed as failed. Leaving it open is the older, honest gap.
|
|
803
|
+
// * it runs AFTER the teardown and each tick is guarded, because emitProgress calls consumer
|
|
804
|
+
// code: inside the finally an onProgress throw skipped the handle release, the unsubscribes
|
|
805
|
+
// and dispose(). Dropping the tick mirrors deliver()'s contract for a throwing steer().
|
|
806
|
+
if (!ok) {
|
|
807
|
+
for (const [callId, name] of openToolCalls) {
|
|
808
|
+
try {
|
|
809
|
+
emitProgress(snapshot(state, { phase: "end", callId, name, failed: true }));
|
|
810
|
+
} catch {
|
|
811
|
+
/* the consumer's sink threw; the tick is lost, the run still settles */
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
|
|
817
|
+
// A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
|
|
818
|
+
if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
819
|
+
// The deadline cannot tell the two causes apart, so the message names both: claiming
|
|
820
|
+
// "it never started" would misdirect the operator of a leg that did start and was
|
|
821
|
+
// simply waiting on a slow first response (mirrors the child engine's wording).
|
|
822
|
+
else if (startupTimedOut)
|
|
823
|
+
result.error = `${tag} agent produced no output within the ${startupMs}ms startup window — either it never started (a stalled init) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
824
|
+
else if (hardTimedOut) result.error = `${tag} agent exceeded the ${hardMs}ms hard cap${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
825
|
+
else if (aborted) result.error = `${tag} agent aborted`;
|
|
826
|
+
else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
|
|
827
|
+
else if (thrownError) result.error = `${tag} ${thrownError}`;
|
|
828
|
+
else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
|
|
829
|
+
// Classify the failure so the fallback decorator can reroute ONLY provider errors,
|
|
830
|
+
// never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
|
|
831
|
+
// own report of an API failure → provider. A THROWN error is not automatically a
|
|
832
|
+
// provider fault (bugs and tool failures throw too) — classify by its text.
|
|
833
|
+
if (!ok)
|
|
834
|
+
result.failureKind =
|
|
835
|
+
timedOut || hardTimedOut || startupTimedOut
|
|
836
|
+
? "timeout"
|
|
837
|
+
: aborted
|
|
838
|
+
? "abort"
|
|
839
|
+
: state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
|
|
840
|
+
? "provider"
|
|
841
|
+
: "agent";
|
|
842
|
+
|
|
843
|
+
if (contractDef) {
|
|
844
|
+
const v = parseAndValidate(state.output, contractDef);
|
|
845
|
+
if (v.value) result.structured = v.value;
|
|
846
|
+
if (!v.ok) {
|
|
847
|
+
result.ok = false;
|
|
848
|
+
// The cause of death (timeout/abort/provider) wins over the contract miss —
|
|
849
|
+
// naming "not valid JSON" over an empty output hides the real failure.
|
|
850
|
+
if (v.error && result.error === undefined) result.error = v.error;
|
|
851
|
+
if (result.failureKind === undefined) result.failureKind = "contract";
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return result;
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|