@aeondave/pi-persona 1.17.3 → 1.17.5
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 +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- package/src/ui/strategy-leg.ts +40 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Per-leg effort selection. Pi alone owns model-capability clamping. Pure, no host imports. */
|
|
2
|
+
import { isThinkingLevel, THINKING_LEVELS, type ThinkingLevel } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** An explicit invalid selection is a configuration error, never an instruction to inherit. */
|
|
5
|
+
export function parseLegThinking(value: unknown, label = "thinking"): ThinkingLevel | undefined {
|
|
6
|
+
if (value === undefined) return undefined;
|
|
7
|
+
if (!isThinkingLevel(value)) throw new Error(`${label} must be one of: ${THINKING_LEVELS.join(", ")}`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One shared display formatter for effort on delegate launch cards: the FULL level name,
|
|
12
|
+
* with `off` rendering nothing so absence is unambiguous. Unknown input renders nothing too
|
|
13
|
+
* (render paths must never throw); the compact siglas live in the tree/overlay view, not here. */
|
|
14
|
+
const EFFORT_FULL_NAMES: Record<ThinkingLevel, string> = {
|
|
15
|
+
off: "",
|
|
16
|
+
minimal: "minimal",
|
|
17
|
+
low: "low",
|
|
18
|
+
medium: "medium",
|
|
19
|
+
high: "high",
|
|
20
|
+
xhigh: "xhigh",
|
|
21
|
+
max: "max",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function effortFullName(level: ThinkingLevel): string {
|
|
25
|
+
return EFFORT_FULL_NAMES[level] ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The COMPACT siglas for the agent-tree/overlay rows (frozen convention): minimal→mn, low→l,
|
|
29
|
+
* medium→m, high→h, xhigh→xh, max→mx — with `off` rendering nothing so absence is unambiguous.
|
|
30
|
+
* Unknown input renders nothing too (render paths must never throw). The delegate CARDS keep the
|
|
31
|
+
* full names via {@link effortFullName}; the F9 drill-in uses those full names through the same
|
|
32
|
+
* formatter — only the tree/overlay ROWS are compact. */
|
|
33
|
+
const EFFORT_SIGLAS: Record<ThinkingLevel, string> = {
|
|
34
|
+
off: "",
|
|
35
|
+
minimal: "mn",
|
|
36
|
+
low: "l",
|
|
37
|
+
medium: "m",
|
|
38
|
+
high: "h",
|
|
39
|
+
xhigh: "xh",
|
|
40
|
+
max: "mx",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function effortSigla(level: ThinkingLevel): string {
|
|
44
|
+
return EFFORT_SIGLAS[level] ?? "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Explicit spec (including a roster member) > agent default > configured/inherited child effort.
|
|
48
|
+
* Validate explicit fields even when another field wins; never normalize a typo into inheritance. */
|
|
49
|
+
export function resolveLegThinking(
|
|
50
|
+
spec: { thinking?: ThinkingLevel },
|
|
51
|
+
agent: { thinking?: ThinkingLevel },
|
|
52
|
+
childThinking: unknown,
|
|
53
|
+
): ThinkingLevel | undefined {
|
|
54
|
+
const explicit = parseLegThinking(spec.thinking);
|
|
55
|
+
const configured = parseLegThinking(agent.thinking, "agent.thinking");
|
|
56
|
+
return explicit ?? configured ?? (isThinkingLevel(childThinking) ? childThinking : undefined);
|
|
57
|
+
}
|
package/src/core/ownership.ts
CHANGED
|
@@ -77,8 +77,7 @@ export function writeSetPathError(path: string): string | undefined {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Return an actionable error for a parallel write-set collision; undefined means safe to spawn.
|
|
80
|
-
* Lives here
|
|
81
|
-
* module — `delegate.ts` re-exports it unchanged for its own call sites. */
|
|
80
|
+
* Lives here so strategies and tools can depend on ownership without importing each other. */
|
|
82
81
|
export function validateParallelWriteSets(tasks: readonly WriteSetOwner[]): string | undefined {
|
|
83
82
|
for (const [index, task] of tasks.entries()) {
|
|
84
83
|
for (const path of task.writeSet ?? []) {
|
package/src/core/pi-compat.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { dirname, join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
|
|
6
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
export const MIN_PI_VERSION = "1.
|
|
5
|
+
/** API baseline of the current stable Pi target. Update with the four pinned development
|
|
6
|
+
* packages and qualify the installed artifact; historical host releases are not maintained.
|
|
7
|
+
* Keep README.md aligned. Host peers remain "*" because Pi supplies them through its loader. */
|
|
8
|
+
export const MIN_PI_VERSION = "1.4.0";
|
|
10
9
|
|
|
11
10
|
function parse(v: string): [number, number, number] | undefined {
|
|
12
11
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
@@ -21,15 +20,9 @@ export function satisfiesFloor(version: string, floor: string): boolean {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
function resolveHostEntry(): string | undefined {
|
|
24
|
-
// The host
|
|
25
|
-
// ERR_PACKAGE_PATH_NOT_EXPORTED for it — `import.meta.resolve` is tried first because it
|
|
26
|
-
// follows an `exports` map with no `main`/`require` condition. `createRequire` stays as a
|
|
27
|
-
// fallback for any host build that does publish one.
|
|
23
|
+
// The current host exposes its entry through the ESM import condition.
|
|
28
24
|
try {
|
|
29
25
|
return fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
30
|
-
} catch { /* fall through */ }
|
|
31
|
-
try {
|
|
32
|
-
return createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent");
|
|
33
26
|
} catch { /* unresolved host */ }
|
|
34
27
|
return undefined;
|
|
35
28
|
}
|
package/src/core/session-runs.ts
CHANGED
|
@@ -83,5 +83,5 @@ export function restoreNotice(lost: LostWork): string | undefined {
|
|
|
83
83
|
if (lost.monitors > 0) parts.push(plural(lost.monitors, "monitor"));
|
|
84
84
|
if (parts.length === 0) return undefined;
|
|
85
85
|
return `Session restored. Background work from before the restore did not survive (it lives only in memory): ${parts.join("; ")}. `
|
|
86
|
-
+ "
|
|
86
|
+
+ "Runtime workers and live run handles are not restored. Retained terminal reports may still be available via intercom { action:'results' } and result { ref:'result:<UUID>' }; read saved data without restarting work. Do not wait for vanished workers; relaunch unfinished work or re-arm wake sources only when still needed.";
|
|
87
87
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { Permission };
|
|
|
14
14
|
* new level here too — `pi.getThinkingLevel()` returns the upstream union, so a missing member here
|
|
15
15
|
* is a hard compile error where the host reads it (see `PersonaHost.getThinkingLevel`). */
|
|
16
16
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
17
|
-
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
17
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
18
18
|
|
|
19
19
|
export function isThinkingLevel(x: unknown): x is ThinkingLevel {
|
|
20
20
|
return typeof x === "string" && (THINKING_LEVELS as readonly string[]).includes(x);
|
package/src/engine/adapter.ts
CHANGED
|
@@ -10,7 +10,10 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import type { AgentConfig } from "../agents/agent.ts";
|
|
11
11
|
import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
|
|
12
12
|
import { sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
13
|
+
import { resolveLegThinking } from "../core/leg-thinking.ts";
|
|
14
|
+
import type { ThinkingLevel } from "../core/types.ts";
|
|
13
15
|
import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
16
|
+
import { MEMORY_PERSONA_ENV } from "../core/companion.ts";
|
|
14
17
|
import { roleHint } from "../orchestration/roster.ts";
|
|
15
18
|
import { type AgentProgress, type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
16
19
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
@@ -18,8 +21,13 @@ import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./chi
|
|
|
18
21
|
import { looksLikeProviderError } from "./errors.ts";
|
|
19
22
|
import { piHostModuleHint } from "./host-module.ts";
|
|
20
23
|
import { nextChildHandle } from "./handles.ts";
|
|
24
|
+
import { admitResultReadTool, referenceHandoffFailure } from "./result-grant.ts";
|
|
25
|
+
import { makeScopedResultReader, type ResultArchiveView } from "../results/retrieval.ts";
|
|
26
|
+
import type { PreparedResultReader, ScopedReader } from "../bus/result-reader.ts";
|
|
21
27
|
import { combineSignals } from "./signals.ts";
|
|
28
|
+
import { emptyUsage } from "./stream.ts";
|
|
22
29
|
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
30
|
+
import { mergeSessionToolAllowlist } from "./session-tools.ts";
|
|
23
31
|
|
|
24
32
|
/** The engine-side face of the cross-process broker (spec B3/B4/B7) — a child-engine
|
|
25
33
|
* spawn's connection to the supervisor-hosted relay. The concrete implementation
|
|
@@ -40,9 +48,15 @@ export interface EngineAdapterBroker {
|
|
|
40
48
|
* parent-side idle/startup watchdogs consult this before killing a silent leg (R9): a leg
|
|
41
49
|
* blocked on the supervisor is re-armed, not killed. */
|
|
42
50
|
hasPendingAskFrom(handle: string): boolean;
|
|
51
|
+
/** Strict, AWAITED result-reader preflight for a reference-handoff leg (the broker host binds
|
|
52
|
+
* a single-use token to this handle). Must complete BEFORE `register` and the spawn — a
|
|
53
|
+
* missing broker/binding fails the leg closed with zero usage instead of degrading bus-less. */
|
|
54
|
+
prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader>;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export interface EngineAdapterDeps {
|
|
58
|
+
/** Immutable owning supervisor persona; null explicitly means no persona. */
|
|
59
|
+
memoryPersona?: string | null;
|
|
46
60
|
resolveAgent: (name: string) => AgentConfig | undefined;
|
|
47
61
|
contracts?: (name: string) => ContractDef | undefined;
|
|
48
62
|
signal?: AbortSignal;
|
|
@@ -55,8 +69,7 @@ export interface EngineAdapterDeps {
|
|
|
55
69
|
* the on-the-fly role this run carries, when it has one, so a member's own assignment key
|
|
56
70
|
* (`agent#hash`) resolves ahead of the bare agent's. */
|
|
57
71
|
modelFor?: (agent: string, role?: string) => string | undefined;
|
|
58
|
-
/**
|
|
59
|
-
* fall into a model's default "adaptive" mode, which some models reject. */
|
|
72
|
+
/** Configured/inherited child effort, below explicit leg and agent selections. */
|
|
60
73
|
childThinking?: string;
|
|
61
74
|
/** The shared behavioral layer (docs/SPINE.md), prepended to every leg's prompt. This is the
|
|
62
75
|
* WORKER variant (`prompts/spine.worker.md`), not the supervisor text — a leg runs headless
|
|
@@ -80,9 +93,15 @@ export interface EngineAdapterDeps {
|
|
|
80
93
|
* blocking would deadlock: default false, mirroring `inproc.ts`'s `allowBlocking`
|
|
81
94
|
* default. Forwarded to the child as `PI_PERSONA_ALLOW_BLOCKING` (bridge.ts). */
|
|
82
95
|
allowBlocking?: boolean;
|
|
96
|
+
/** The owning session's terminal archive (a GETTER — the owner can change across sessions).
|
|
97
|
+
* Required by any leg carrying `resultRefs` (flow reference handoff): the scoped reader
|
|
98
|
+
* handed to the broker pages THIS archive, after a zero-usage preflight verified every
|
|
99
|
+
* granted ref is durably stored. Absent ⇒ such legs fail closed before any spawn. */
|
|
100
|
+
resultArchive?: () => ResultArchiveView | undefined;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
104
|
+
const memoryPersona = deps.memoryPersona;
|
|
86
105
|
// Per-run contract pinning (I3): an engine instance is created per run, so the first
|
|
87
106
|
// time a contract is used we pin `name@hash` and reuse that frozen snapshot for the
|
|
88
107
|
// rest of the run — hot-reloading a contract file can't change an in-flight run.
|
|
@@ -113,10 +132,38 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
113
132
|
): Promise<AgentResult> {
|
|
114
133
|
const cfg = deps.resolveAgent(spec.agent);
|
|
115
134
|
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
135
|
+
let thinkingRequested: ThinkingLevel | undefined;
|
|
136
|
+
try {
|
|
137
|
+
thinkingRequested = resolveLegThinking(spec, cfg, deps.childThinking);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, failureKind: "contract", error: `[${spec.agent}] ${error instanceof Error ? error.message : String(error)}` };
|
|
140
|
+
}
|
|
116
141
|
const tools = spec.tools ?? cfg.tools;
|
|
117
142
|
const invalidTools = toolModifierFailure(spec.agent, tools);
|
|
118
143
|
if (invalidTools) return invalidTools;
|
|
119
144
|
|
|
145
|
+
// ── reference handoff preflight (contract 3/4) — BEFORE any broker work or spawn ──
|
|
146
|
+
// Admission uses the SAME shared rule as the in-process engine; storage is verified
|
|
147
|
+
// durable (session-entry); the broker binding is AWAITED before registration/spawn.
|
|
148
|
+
// Any miss is a zero-usage contract failure and the child is never invoked.
|
|
149
|
+
let admittedTools = tools;
|
|
150
|
+
let resultReader: ScopedReader | undefined;
|
|
151
|
+
if (spec.resultRefs !== undefined && spec.resultRefs.length > 0) {
|
|
152
|
+
const grant = admitResultReadTool({
|
|
153
|
+
specTools: spec.tools, cfgTools: cfg.tools, excludeTools: cfg.excludeTools,
|
|
154
|
+
canUseBus: deps.canUseBus ?? true,
|
|
155
|
+
});
|
|
156
|
+
if (!grant.ok) return referenceHandoffFailure(spec.agent, grant.error);
|
|
157
|
+
const archive = deps.resultArchive?.();
|
|
158
|
+
if (archive === undefined) return referenceHandoffFailure(spec.agent, "terminal result archive unavailable for reference handoff");
|
|
159
|
+
const scoped = makeScopedResultReader(archive, spec.resultRefs);
|
|
160
|
+
const { missing } = scoped.preflight();
|
|
161
|
+
if (missing.length > 0) return referenceHandoffFailure(spec.agent, `archived results unavailable: ${missing.join(", ")}`);
|
|
162
|
+
if (deps.broker === undefined) return referenceHandoffFailure(spec.agent, "cross-process broker reader unavailable for reference handoff");
|
|
163
|
+
resultReader = scoped;
|
|
164
|
+
admittedTools = grant.tools;
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
// Resolved (and pinned) up front: the SAME def both instructs the member and
|
|
121
168
|
// validates its output — an instruction/validation drift is impossible. A named
|
|
122
169
|
// contract that cannot be resolved is a hard configuration error: running the leg
|
|
@@ -130,12 +177,10 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
130
177
|
: spec.task;
|
|
131
178
|
const task = contractDef ? `${withSkills}\n\n${contractInstructions(contractDef)}` : withSkills;
|
|
132
179
|
const childSpec: ChildRunSpec = { task };
|
|
133
|
-
|
|
134
|
-
// Append an explicit thinking level (model:level) unless one is already present —
|
|
135
|
-
// a child without it defaults to "adaptive", which some models reject.
|
|
136
|
-
if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
|
|
180
|
+
const model = spec.model ?? deps.modelFor?.(spec.agent, spec.role) ?? cfg.model;
|
|
137
181
|
if (model) childSpec.model = model;
|
|
138
|
-
if (
|
|
182
|
+
if (thinkingRequested !== undefined) childSpec.thinking = thinkingRequested;
|
|
183
|
+
if (admittedTools !== undefined) childSpec.tools = admittedTools;
|
|
139
184
|
if (cfg.excludeTools && cfg.excludeTools.length > 0) childSpec.excludeTools = cfg.excludeTools;
|
|
140
185
|
// The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
|
|
141
186
|
// on-the-fly `role` specialisation from the spec. All three go out as
|
|
@@ -147,6 +192,12 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
147
192
|
if (deps.cwd) childSpec.cwd = deps.cwd;
|
|
148
193
|
|
|
149
194
|
const childOptions: ChildEngineOptions = { ...deps.childOptions };
|
|
195
|
+
if (memoryPersona !== undefined || childOptions.env?.[MEMORY_PERSONA_ENV] !== undefined) {
|
|
196
|
+
const env = { ...childOptions.env };
|
|
197
|
+
delete env[MEMORY_PERSONA_ENV];
|
|
198
|
+
if (memoryPersona !== undefined) env[MEMORY_PERSONA_ENV] = JSON.stringify({ version: 1, persona: memoryPersona });
|
|
199
|
+
childOptions.env = env;
|
|
200
|
+
}
|
|
150
201
|
// NP2: a per-leg timeoutMs override raises (or shortens) just THIS leg's idle-timeout
|
|
151
202
|
// ceiling without touching the shared default other legs of the same run still see.
|
|
152
203
|
// Junk (non-finite/≤0) is ignored — the engine's existing childOptions.timeoutMs stands.
|
|
@@ -160,27 +211,46 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
160
211
|
|
|
161
212
|
// Cross-process comm plane (spec B1-B7): absent broker ⇒ none of this runs, so
|
|
162
213
|
// the spawn env + behaviour stay byte-identical to pre-broker pi-persona.
|
|
163
|
-
// Present ⇒ mint a handle,
|
|
164
|
-
//
|
|
214
|
+
// Present ⇒ mint a handle, (for a reference-handoff leg) AWAIT the strict reader
|
|
215
|
+
// binding BEFORE registration, register the handle with the host BEFORE spawn, wire
|
|
216
|
+
// the child's env, and give the caller a steer function.
|
|
165
217
|
let handle: string | undefined;
|
|
218
|
+
let preparedReader: PreparedResultReader | undefined;
|
|
166
219
|
if (deps.broker) {
|
|
167
|
-
|
|
220
|
+
const broker = deps.broker;
|
|
221
|
+
handle = nextChildHandle(spec.agent);
|
|
168
222
|
const wantsPeers = spec.peers === true && (deps.canUseBus ?? true);
|
|
169
|
-
const
|
|
223
|
+
const busTools = (deps.canUseBus ?? true) ? ["contact_supervisor", ...(wantsPeers ? ["contact_peer"] : [])] : [];
|
|
224
|
+
const mergedTools = mergeSessionToolAllowlist(admittedTools, busTools);
|
|
225
|
+
if (mergedTools !== undefined) childSpec.tools = mergedTools;
|
|
226
|
+
const label = spec.name?.trim()
|
|
170
227
|
? sanitizeDisplayLabel(spec.name, handle)
|
|
171
228
|
: spec.role ? `${handle} (${roleHint(spec.role)})` : handle;
|
|
172
|
-
|
|
229
|
+
if (resultReader !== undefined) {
|
|
230
|
+
try {
|
|
231
|
+
preparedReader = await broker.prepareResultReader(handle, resultReader, signal);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return referenceHandoffFailure(spec.agent, `broker result reader preparation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
234
|
+
}
|
|
235
|
+
childOptions.env = {
|
|
236
|
+
...childOptions.env,
|
|
237
|
+
// Private spawn credential for the child's bridge (bridge.ts installs read_result
|
|
238
|
+
// only when this is set); scrubbed from the inherited env inside child.ts.
|
|
239
|
+
PI_PERSONA_RESULT_READER_TOKEN: preparedReader.token,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
broker.register({ handle, label, group: peerGroup, ...(wantsPeers ? { peers: true } : {}) });
|
|
173
243
|
childOptions.env = {
|
|
174
244
|
...childOptions.env,
|
|
175
|
-
PI_PERSONA_BUS:
|
|
245
|
+
PI_PERSONA_BUS: broker.endpoint,
|
|
176
246
|
PI_PERSONA_HANDLE: handle,
|
|
247
|
+
PI_PERSONA_BUS_TOOLS: (deps.canUseBus ?? true) ? "on" : "off",
|
|
177
248
|
...(wantsPeers ? { PI_PERSONA_PEERS: "1" } : {}),
|
|
178
249
|
...(deps.allowBlocking ? { PI_PERSONA_ALLOW_BLOCKING: "1" } : {}),
|
|
179
250
|
};
|
|
180
251
|
// The parent-side watchdogs must not kill a leg that is genuinely waiting on the
|
|
181
252
|
// supervisor's answer (R9, inproc parity) — ask the bus, don't guess from a static flag.
|
|
182
|
-
childOptions.isBlocked = () =>
|
|
183
|
-
const broker = deps.broker;
|
|
253
|
+
childOptions.isBlocked = () => broker.hasPendingAskFrom(handle!);
|
|
184
254
|
const h = handle;
|
|
185
255
|
const steer: SteerFn = Object.assign((text: string) => broker.steerFrame(h, text), {
|
|
186
256
|
isWaitingForSupervisor: () => broker.hasPendingAskFrom(h),
|
|
@@ -192,17 +262,27 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
192
262
|
try {
|
|
193
263
|
child = await runChildAgent(childSpec, signal, childOptions);
|
|
194
264
|
} finally {
|
|
195
|
-
//
|
|
196
|
-
// a leaked
|
|
265
|
+
// Revoke the reader grant and unregister on EVERY settle path (success, failure, or
|
|
266
|
+
// an unexpected throw) — a leaked grant would outlive its leg; a leaked handle would
|
|
267
|
+
// otherwise linger in the host's registries forever.
|
|
268
|
+
preparedReader?.revoke();
|
|
197
269
|
if (deps.broker && handle) deps.broker.unregister(handle);
|
|
198
270
|
}
|
|
199
271
|
|
|
200
272
|
const result: AgentResult = { agent: spec.agent, output: child.output, usage: child.usage, ok: child.ok };
|
|
201
|
-
|
|
273
|
+
if (thinkingRequested !== undefined) result.thinkingRequested = thinkingRequested;
|
|
274
|
+
if (child.thinkingEffective !== undefined) result.thinkingEffective = child.thinkingEffective;
|
|
275
|
+
// The opaque provider/id the child ran on — for the UI and
|
|
202
276
|
// as the seed of the provider-fallback chain on a provider failure. The child's
|
|
203
277
|
// STREAM-REPORTED model wins over the requested spec: a resolved alias or a
|
|
204
|
-
// provider-side redirect must seed the fallback chain with what actually ran.
|
|
205
|
-
|
|
278
|
+
// provider-side redirect must seed the fallback chain with what actually ran. Pi's native
|
|
279
|
+
// assistant message reports a bare model id: retain the pinned provider ONLY when that
|
|
280
|
+
// id exactly matches, never attach it to a different observed model or qualified route.
|
|
281
|
+
const requestedModel = childSpec.model ?? spec.model ?? cfg.model;
|
|
282
|
+
const observedModel = child.model?.trim() || undefined;
|
|
283
|
+
const modelUsed = observedModel && !observedModel.includes("/") && requestedModel?.includes("/")
|
|
284
|
+
&& requestedModel.slice(requestedModel.indexOf("/") + 1) === observedModel
|
|
285
|
+
? requestedModel : observedModel ?? requestedModel;
|
|
206
286
|
if (modelUsed) result.modelUsed = modelUsed;
|
|
207
287
|
// Diagnostic tag: agent · model ref · dynamic overrides. Same shape as inproc engine,
|
|
208
288
|
// so failed follow-ups always say WHICH agent+model died and WHY.
|
package/src/engine/async.ts
CHANGED
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
|
|
13
13
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
14
|
+
import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
|
|
14
15
|
import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
|
|
15
16
|
import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
|
|
16
17
|
import { compactTokens } from "../core/format.ts";
|
|
18
|
+
import { isThinkingLevel } from "../core/types.ts";
|
|
17
19
|
import { formatDuration } from "../core/time.ts";
|
|
18
20
|
|
|
19
|
-
export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
|
|
20
|
-
|
|
21
21
|
/** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
|
|
22
22
|
* badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
23
23
|
* visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
|
|
@@ -33,6 +33,7 @@ export interface AsyncRun {
|
|
|
33
33
|
status: "running" | "done" | "failed" | "stopped";
|
|
34
34
|
progress: ProgressSnapshot;
|
|
35
35
|
result?: AgentResult;
|
|
36
|
+
resultReceipt?: ResultReceipt;
|
|
36
37
|
error?: string;
|
|
37
38
|
/** The codename the launcher gave this run (e.g. "atlas-static") — the SAME name shown on the
|
|
38
39
|
* agent-tree node for this run. When set, it's the canonical display name (over the bare
|
|
@@ -70,6 +71,8 @@ export interface AsyncRun {
|
|
|
70
71
|
/** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
|
|
71
72
|
* last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
|
|
72
73
|
batch?: string;
|
|
74
|
+
/** Companion-owned maintenance settles to its owner without waking the supervisor model. */
|
|
75
|
+
delivery?: "companion";
|
|
73
76
|
/** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
|
|
74
77
|
* digest instead of a stall verdict, because silence while a human decides is not a wedge. */
|
|
75
78
|
awaiting?: string;
|
|
@@ -115,6 +118,7 @@ export class AsyncRunTracker {
|
|
|
115
118
|
/** Ids whose payload was evicted while STILL UNCOLLECTED, oldest first (insertion-ordered Set). */
|
|
116
119
|
private readonly droppedIds = new Set<string>();
|
|
117
120
|
private readonly completeListeners: Array<(run: AsyncRun) => void> = [];
|
|
121
|
+
private readonly createListeners: Array<(run: AsyncRun) => void> = [];
|
|
118
122
|
private seq = 0;
|
|
119
123
|
private readonly maxRetained: number;
|
|
120
124
|
private readonly now: () => number;
|
|
@@ -138,7 +142,7 @@ export class AsyncRunTracker {
|
|
|
138
142
|
if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
|
|
145
|
+
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"]; delivery?: AsyncRun["delivery"] }, run: RunThunk): string {
|
|
142
146
|
this.seq += 1;
|
|
143
147
|
const id = `run-${this.seq.toString(36)}`;
|
|
144
148
|
const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
|
|
@@ -153,12 +157,16 @@ export class AsyncRunTracker {
|
|
|
153
157
|
};
|
|
154
158
|
if (meta.mutates) entry.mutates = true;
|
|
155
159
|
if (meta.batch) entry.batch = meta.batch;
|
|
160
|
+
if (meta.delivery) entry.delivery = meta.delivery;
|
|
156
161
|
// Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
|
|
157
162
|
// once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
|
|
158
163
|
// those views; renderers still sanitize defensively when they compose a label.
|
|
159
164
|
if (meta.label !== undefined) entry.label = sanitizeDisplayLabel(meta.label);
|
|
160
165
|
if (meta.model !== undefined) entry.model = sanitizeDisplayLabel(meta.model, "model");
|
|
161
166
|
this.runs.set(id, entry);
|
|
167
|
+
for (const cb of [...this.createListeners]) {
|
|
168
|
+
try { cb(entry); } catch { /* archival observation cannot prevent launch */ }
|
|
169
|
+
}
|
|
162
170
|
|
|
163
171
|
const settle = (): void => this.settleOnce(entry);
|
|
164
172
|
// `Promise.resolve(run(...))` routes BOTH a returned rejection AND a SYNCHRONOUS throw from
|
|
@@ -176,7 +184,9 @@ export class AsyncRunTracker {
|
|
|
176
184
|
.then((result) => {
|
|
177
185
|
if (entry.settled) return; // force-settled by the supervisor — drop the late natural result
|
|
178
186
|
entry.status = result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed";
|
|
179
|
-
|
|
187
|
+
// Retain scalar metadata by value: an engine reusing/mutating its result object
|
|
188
|
+
// after settlement must not rewrite this run's requested/observed effort.
|
|
189
|
+
entry.result = { ...result, usage: { ...result.usage }, ...(result.resultReceipt ? { resultReceipt: { ...result.resultReceipt } } : {}) };
|
|
180
190
|
if (!result.ok && result.error) entry.error = result.error;
|
|
181
191
|
})
|
|
182
192
|
.catch((err: unknown) => {
|
|
@@ -347,6 +357,14 @@ export class AsyncRunTracker {
|
|
|
347
357
|
return batch === undefined || !this.running().some((r) => r.batch === batch);
|
|
348
358
|
}
|
|
349
359
|
|
|
360
|
+
onCreate(cb: (run: AsyncRun) => void): () => void {
|
|
361
|
+
this.createListeners.push(cb);
|
|
362
|
+
return () => {
|
|
363
|
+
const i = this.createListeners.indexOf(cb);
|
|
364
|
+
if (i >= 0) this.createListeners.splice(i, 1);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
350
368
|
onComplete(cb: (run: AsyncRun) => void): () => void {
|
|
351
369
|
this.completeListeners.push(cb);
|
|
352
370
|
return () => {
|
|
@@ -363,10 +381,18 @@ export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
|
363
381
|
return [...byId.values()];
|
|
364
382
|
}
|
|
365
383
|
|
|
384
|
+
/** A terminal engine observation supersedes the launch choice; running/launch rows retain their snapshot. */
|
|
385
|
+
export function observedRunModel(run: Partial<Pick<AsyncRun, "status" | "result">>): string | undefined {
|
|
386
|
+
const model = run.status !== undefined && run.status !== "running" ? run.result?.modelUsed : undefined;
|
|
387
|
+
return model?.trim() ? model.split("/").map((part) => sanitizeDisplayLabel(part, "model", 256)).join("/").slice(0, 256) : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
366
390
|
/** One identity everywhere a tracked run is rendered: the launcher's codename when present,
|
|
367
|
-
* otherwise the agent type, with the
|
|
368
|
-
export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
|
|
369
|
-
const
|
|
391
|
+
* otherwise the agent type, with the observed terminal model or launch model appended once. */
|
|
392
|
+
export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model"> & Partial<Pick<AsyncRun, "status" | "result">>): string {
|
|
393
|
+
const observed = observedRunModel(run);
|
|
394
|
+
const selected = observed ? (observed.split("/").pop() ?? observed).replace(/^claude-/, "") : run.model;
|
|
395
|
+
const model = selected === undefined ? undefined : sanitizeDisplayLabel(selected, "model", 24);
|
|
370
396
|
if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
|
|
371
397
|
const suffix = ` · ${model}`;
|
|
372
398
|
const nameLimit = Math.max(1, MAX_DISPLAY_LABEL_CHARS - suffix.length);
|
|
@@ -618,6 +644,25 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
618
644
|
const stoppedCount = stopped.length > 0 ? `, ${stopped.length} stopped` : "";
|
|
619
645
|
const head = `[pi-persona] ${runs.length} async run${runs.length === 1 ? "" : "s"} settled — ${done.length} done, ${failed.length} failed${stoppedCount}`;
|
|
620
646
|
const blocks: string[] = [head];
|
|
647
|
+
const models = runs.flatMap((run) => {
|
|
648
|
+
const model = observedRunModel(run);
|
|
649
|
+
return model ? [`${run.id}: Observed model: ${model}`] : [];
|
|
650
|
+
});
|
|
651
|
+
if (models.length > 0) blocks.push(`\n${models.join("\n")}`);
|
|
652
|
+
const receipts = runs.slice(0, MAX_ASYNC_STATUS_ROWS).flatMap((run) => {
|
|
653
|
+
const hint = resultReceiptHint(run.resultReceipt);
|
|
654
|
+
return hint ? [`${run.id}: ${hint}`] : [];
|
|
655
|
+
});
|
|
656
|
+
if (receipts.length > 0) blocks.push(`\nTerminal reports (intercom results lists all retained references):\n${receipts.join("\n")}`);
|
|
657
|
+
// Common result metadata, outside untrusted worker prose, on inline and background paths.
|
|
658
|
+
// A host effort observation is a Pi request setting, not a measure of provider cognition.
|
|
659
|
+
const efforts = runs.flatMap((r) => {
|
|
660
|
+
const requested = r.result?.thinkingRequested;
|
|
661
|
+
const observed = r.result?.thinkingEffective;
|
|
662
|
+
if (!isThinkingLevel(requested) && !isThinkingLevel(observed)) return [];
|
|
663
|
+
return [`${r.id}: requested ${isThinkingLevel(requested) ? requested : "unknown"}; observed Pi effort ${isThinkingLevel(observed) ? observed : "unknown"}`];
|
|
664
|
+
});
|
|
665
|
+
if (efforts.length > 0) blocks.push(`\nThinking (not provider cognition):\n${efforts.join("\n")}`);
|
|
621
666
|
for (const r of done) {
|
|
622
667
|
// Only fence REAL output — an empty/whitespace result gets a plain "(no output)", never an
|
|
623
668
|
// empty <fence></fence> shell (there's nothing untrusted to guard, and the empty block reads
|
|
@@ -660,7 +705,7 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
660
705
|
// The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
|
|
661
706
|
// cut keeps, so a cut leg is still fetchable by id instead of silently missing.
|
|
662
707
|
const named = report.length > MAX_COMPLETION_REPORT_CHARS
|
|
663
|
-
? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}
|
|
708
|
+
? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}. Saved reports: intercom { action:"results" }; use result { ref, offset, limit } even if live handles were dropped.`
|
|
664
709
|
: report;
|
|
665
710
|
return boundCompletionSurface(named);
|
|
666
711
|
}
|
|
@@ -697,9 +742,6 @@ export const MAX_EMPTY_RENDER_RETRIES = 3;
|
|
|
697
742
|
export interface IdleNotifierDeps<T> {
|
|
698
743
|
/** Whether the supervisor is idle (not streaming a turn). */
|
|
699
744
|
isIdle: () => boolean;
|
|
700
|
-
/** Legacy host-queue probe. Retained for source compatibility, but deliberately ignored: when
|
|
701
|
-
* the supervisor is idle, starting a turn is what drains any orphaned host follow-ups. */
|
|
702
|
-
hasPending?: () => boolean;
|
|
703
745
|
/** Deliver the coalesced message. Async rejection is treated exactly like a synchronous throw:
|
|
704
746
|
* the batch is restored at the front of the queue and retried. */
|
|
705
747
|
deliver: (message: string) => void | Promise<void>;
|
package/src/engine/child.ts
CHANGED
|
@@ -10,11 +10,13 @@ import * as fs from "node:fs";
|
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
|
+
import type { ThinkingLevel } from "../core/types.ts";
|
|
13
14
|
import { applyEvent, type ChildUsage, createStreamState, feedLines, type ProgressSnapshot, snapshot } from "./stream.ts";
|
|
14
15
|
|
|
15
16
|
export interface ChildRunSpec {
|
|
16
17
|
task: string;
|
|
17
18
|
model?: string;
|
|
19
|
+
thinking?: ThinkingLevel;
|
|
18
20
|
tools?: string[];
|
|
19
21
|
excludeTools?: string[];
|
|
20
22
|
systemPrompt?: string;
|
|
@@ -32,6 +34,7 @@ export interface ChildRunResult {
|
|
|
32
34
|
/** True when the idle watchdog OR the hard wall-clock cap fired. Disjoint from `aborted`. */
|
|
33
35
|
timedOut: boolean;
|
|
34
36
|
model?: string;
|
|
37
|
+
thinkingEffective?: ThinkingLevel;
|
|
35
38
|
stopReason?: string;
|
|
36
39
|
errorMessage?: string;
|
|
37
40
|
}
|
|
@@ -166,6 +169,7 @@ export async function runChildAgent(
|
|
|
166
169
|
|
|
167
170
|
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
168
171
|
if (spec.model) args.push("--model", spec.model);
|
|
172
|
+
if (spec.thinking !== undefined) args.push("--thinking", spec.thinking);
|
|
169
173
|
if (spec.tools !== undefined) {
|
|
170
174
|
if (spec.tools.length > 0) args.push("--tools", spec.tools.join(","));
|
|
171
175
|
else args.push("--no-tools");
|
|
@@ -201,13 +205,17 @@ export async function runChildAgent(
|
|
|
201
205
|
const inv = resolveInvocation(args);
|
|
202
206
|
// A stale broker wiring in the SUPERVISOR's own environment must not leak into a
|
|
203
207
|
// plain child: PI_PERSONA_BUS would boot the child's bridge against a dead (or
|
|
204
|
-
// foreign) endpoint
|
|
205
|
-
//
|
|
208
|
+
// foreign) endpoint, and a stale reader token would arm a read_result tool nobody
|
|
209
|
+
// granted. The adapter re-adds these deliberately via opts.env when a broker (and a
|
|
210
|
+
// result-reader grant) is actually live for THIS run.
|
|
206
211
|
const inherited = { ...process.env };
|
|
207
212
|
delete inherited.PI_PERSONA_BUS;
|
|
208
213
|
delete inherited.PI_PERSONA_HANDLE;
|
|
209
214
|
delete inherited.PI_PERSONA_PEERS;
|
|
210
215
|
delete inherited.PI_PERSONA_ALLOW_BLOCKING;
|
|
216
|
+
delete inherited.PI_PERSONA_BUS_TOOLS;
|
|
217
|
+
delete inherited.PI_PERSONA_MEMORY_PERSONA;
|
|
218
|
+
delete inherited.PI_PERSONA_RESULT_READER_TOKEN;
|
|
211
219
|
const proc = spawn(inv.command, inv.args, {
|
|
212
220
|
cwd: spec.cwd ?? process.cwd(),
|
|
213
221
|
shell: false,
|
|
@@ -496,6 +504,7 @@ export async function runChildAgent(
|
|
|
496
504
|
timedOut: timedOut || hardTimedOut || startupTimedOut,
|
|
497
505
|
};
|
|
498
506
|
if (state.model !== undefined) result.model = state.model;
|
|
507
|
+
if (state.thinkingEffective !== undefined) result.thinkingEffective = state.thinkingEffective;
|
|
499
508
|
if (state.stopReason !== undefined) result.stopReason = state.stopReason;
|
|
500
509
|
// errorMessage precedence: a timeout/abort is the *cause of death*, so label
|
|
501
510
|
// it explicitly and fold in any stream-level error rather than letting the
|