@aeondave/pi-persona 1.15.2 → 1.16.1
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 +32 -6
- package/CHANGELOG.md +97 -0
- package/README.md +27 -8
- package/SECURITY.md +42 -0
- package/docs/ARCHITECTURE.md +52 -7
- package/docs/EXPERIENCE.md +21 -5
- package/docs/README.md +2 -0
- package/docs/REFERENCE.md +84 -14
- package/docs/STRATEGIES.md +58 -31
- package/docs/TELEMETRY.md +14 -0
- package/package.json +18 -11
- package/src/bus/broker/paths.ts +3 -2
- package/src/core/brief.ts +1 -1
- package/src/core/nudge.ts +3 -4
- package/src/core/pi-compat.ts +3 -3
- package/src/engine/adapter.ts +11 -6
- package/src/engine/async.ts +12 -6
- package/src/engine/inproc.ts +14 -6
- package/src/extension/engine.ts +25 -4
- package/src/extension/hooks.ts +68 -21
- package/src/extension.ts +233 -57
- package/src/orchestration/model-retry.ts +32 -61
- package/src/orchestration/params.ts +27 -1
- package/src/orchestration/roster.ts +80 -3
- package/src/orchestration/sdk.ts +150 -36
- package/src/orchestration/strategies/compete.ts +14 -8
- package/src/orchestration/strategies/council-rounds.ts +8 -1
- package/src/orchestration/strategies/critic-loop.ts +25 -11
- package/src/orchestration/strategies/judge.ts +18 -9
- package/src/orchestration/strategies/magi.ts +42 -93
- package/src/orchestration/strategies/map.ts +41 -19
- package/src/orchestration/strategies/pipeline.ts +2 -2
- package/src/orchestration/strategies/synthesize.ts +18 -11
- package/src/orchestration/types.ts +15 -0
- package/src/persona/model-participants.ts +240 -0
- package/src/persona/orchestrate.ts +44 -2
- package/src/persona/persona.ts +86 -14
- package/src/telemetry/contract.ts +1 -1
- package/src/telemetry/producer.ts +241 -8
- package/src/tools/council.ts +36 -8
- package/src/tools/delegate-tool.ts +162 -24
- package/src/tools/intercom-tool.ts +119 -32
- package/src/ui/agent-overlay.ts +15 -6
- package/src/ui/agent-tree.ts +39 -2
package/src/engine/inproc.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
|
30
30
|
import { attributeInbound } from "../core/fence.ts";
|
|
31
31
|
import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
|
|
32
32
|
import { roleHint } from "../orchestration/roster.ts";
|
|
33
|
-
import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
33
|
+
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
34
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
35
35
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
36
|
import { nextChildHandle } from "./handles.ts";
|
|
@@ -94,8 +94,10 @@ export interface InProcessDeps {
|
|
|
94
94
|
cwd: string;
|
|
95
95
|
/** Pi's global agent dir (~/.pi/agent); defaults to cwd if omitted (tests). */
|
|
96
96
|
agentDir?: string;
|
|
97
|
-
/** Per-agent model override (e.g. a persona's configured ensemble models).
|
|
98
|
-
|
|
97
|
+
/** Per-agent model override (e.g. a persona's configured ensemble models). `role` is the
|
|
98
|
+
* on-the-fly role this run carries, when it has one, so a member's own assignment key
|
|
99
|
+
* (`agent#hash`) resolves ahead of the bare agent's. */
|
|
100
|
+
modelFor?: (agent: string, role?: string) => string | undefined;
|
|
99
101
|
/** The session/loader model — the last-resort fallback when an agent declares none
|
|
100
102
|
* (the child engine gets this for free from `pi -p`'s default; inproc must pass it). */
|
|
101
103
|
defaultModel?: string;
|
|
@@ -319,10 +321,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
319
321
|
const requestedDef = requestedContract ? pinnedDef(requestedContract) : undefined;
|
|
320
322
|
if (requestedContract && !requestedDef) return unknownContractFailure(spec.agent, requestedContract, deps.listContracts?.() ?? []);
|
|
321
323
|
|
|
322
|
-
const
|
|
324
|
+
const picked = spec.model ? undefined : deps.modelFor?.(spec.agent, spec.role);
|
|
325
|
+
const ref = spec.model ?? picked ?? cfg.model ?? deps.defaultModel;
|
|
323
326
|
const model = resolveModel(deps.modelRegistry, ref);
|
|
324
327
|
if (!model) {
|
|
325
|
-
const src = spec.model ? "spec" :
|
|
328
|
+
const src = spec.model ? "spec" : picked ? "agent picker" : cfg.model ? "agent config" : "default";
|
|
326
329
|
// Make the failure self-correcting: name a few real registry refs (nearest
|
|
327
330
|
// matches first) so the supervisor's retry can pick a valid model at once.
|
|
328
331
|
const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
|
|
@@ -681,7 +684,9 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
681
684
|
|
|
682
685
|
// Steering: the in-process engine can inject a user message into the running
|
|
683
686
|
// agent — the v0.4 payoff the one-shot child engine can't do.
|
|
684
|
-
|
|
687
|
+
const waitingHandle = childHandle;
|
|
688
|
+
const waitingBus = deps.bus;
|
|
689
|
+
const steer: SteerFn = Object.assign((text: string): boolean => {
|
|
685
690
|
if (!steerable) return false;
|
|
686
691
|
const trimmed = text.trim();
|
|
687
692
|
if (!trimmed) return false;
|
|
@@ -691,7 +696,10 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
691
696
|
} catch {
|
|
692
697
|
return false;
|
|
693
698
|
}
|
|
699
|
+
}, {
|
|
700
|
+
isWaitingForSupervisor: () => waitingHandle !== undefined && waitingBus?.hasPendingAskFrom(waitingHandle) === true,
|
|
694
701
|
});
|
|
702
|
+
onSteerable?.(steer);
|
|
695
703
|
|
|
696
704
|
// Delivery bridge: bus messages addressed to this child are steered into its live
|
|
697
705
|
// session as fenced, attributed user turns — attribution stays OUTSIDE the fence so
|
package/src/extension/engine.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { RunLimits } from "../core/capabilities.ts";
|
|
|
9
9
|
import type { PiPersonaConfig } from "../core/config.ts";
|
|
10
10
|
import { isThinkingLevel } from "../core/types.ts";
|
|
11
11
|
import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
|
|
12
|
+
import { participantKey } from "../persona/model-participants.ts";
|
|
12
13
|
import type { PersonaController, PersonaHost } from "../persona/controller.ts";
|
|
13
14
|
import { type EngineAdapterBroker, type EngineAdapterDeps, makeEngine } from "../engine/adapter.ts";
|
|
14
15
|
import { captureStatus, diffStatus, findGitRoot, renderChangeReport } from "../engine/change-report.ts";
|
|
@@ -50,7 +51,17 @@ export interface BuildEngineDeps {
|
|
|
50
51
|
gitExec?: GitExec;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export type BuildEngine = (
|
|
54
|
+
export type BuildEngine = (
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
onProgress?: (s: ProgressSnapshot) => void,
|
|
57
|
+
engOpts?: {
|
|
58
|
+
async?: boolean;
|
|
59
|
+
/** Wrap the engine in `withModelFallback` (default true). Strategy/flow builds pass
|
|
60
|
+
* `false`: there the SDK's own main-model recovery is the ONE reroute, so an arbitrary
|
|
61
|
+
* provider must not be substituted underneath a member the user pinned. */
|
|
62
|
+
providerFallback?: boolean;
|
|
63
|
+
},
|
|
64
|
+
) => StrategyEngine;
|
|
54
65
|
|
|
55
66
|
export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
56
67
|
return (signal, onProgress, engOpts) => {
|
|
@@ -63,9 +74,18 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
63
74
|
// A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
|
|
64
75
|
const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
|
|
65
76
|
const contractNames = (): string[] => installedContractNames(contractDefs);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
// A per-persona model assignment. The lookup key MUST match the one the picker saved
|
|
78
|
+
// (`persona/model-participants.ts`): a role member's own key first, then the bare agent
|
|
79
|
+
// key, so assignments saved before roles existed keep applying.
|
|
80
|
+
const persona = controller.activePersona?.name;
|
|
81
|
+
const savedModels = persona ? personaModels(personaConfigs, persona) : undefined;
|
|
82
|
+
const modelFor = (agent: string, role?: string): string | undefined => {
|
|
83
|
+
const trimmed = role?.trim();
|
|
84
|
+
if (trimmed) {
|
|
85
|
+
const own = savedModels?.[participantKey(agent, trimmed)];
|
|
86
|
+
if (own) return own;
|
|
87
|
+
}
|
|
88
|
+
return savedModels?.[agent];
|
|
69
89
|
};
|
|
70
90
|
// The main model thinks adaptively (it picks effort by difficulty); a spawned child
|
|
71
91
|
// can't inherit "adaptive" if its model doesn't support it, so give children an
|
|
@@ -184,6 +204,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
184
204
|
// switch on error". No ctx (no registry) ⇒ pass through. Each attempt still runs through
|
|
185
205
|
// worktree isolation + steering below.
|
|
186
206
|
const wrapFallback = (eng: StrategyEngine): StrategyEngine => {
|
|
207
|
+
if (engOpts?.providerFallback === false) return eng; // the SDK owns recovery there
|
|
187
208
|
if (!lastCtx) return eng;
|
|
188
209
|
const prefer = lastCtx.model?.provider;
|
|
189
210
|
return withModelFallback(eng, {
|
package/src/extension/hooks.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
|
|
|
10
10
|
import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
|
|
11
11
|
import type { PiPersonaConfig } from "../core/config.ts";
|
|
12
12
|
import { fenceUntrusted } from "../core/fence.ts";
|
|
13
|
-
import type
|
|
13
|
+
import { HANDOFF_TOOL_NAMES, type DelegationNudge, type PersistenceNudge } from "../core/nudge.ts";
|
|
14
14
|
import { buildSessionAnchor } from "../core/time.ts";
|
|
15
15
|
import { readClockSnapshot } from "./clock.ts";
|
|
16
16
|
import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
@@ -34,6 +34,7 @@ import type { PersonaConfigStore } from "../persona/config-store.ts";
|
|
|
34
34
|
import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
|
|
35
35
|
import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
|
|
36
36
|
import type { AgentTree, AgentNode } from "../ui/agent-tree.ts";
|
|
37
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
37
38
|
import type { FailureKind } from "../orchestration/types.ts";
|
|
38
39
|
import type { ExocomInstall } from "../exocom/install.ts";
|
|
39
40
|
import type { SessionIdentity } from "./identity.ts";
|
|
@@ -113,8 +114,19 @@ export interface HookHost {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstall): void {
|
|
117
|
+
// Only parent ids are retained, never child output. Propagate through wrappers only when they
|
|
118
|
+
// actually relay a surrender marker; the outer model-visible result owns the note and card.
|
|
119
|
+
const pendingSurrenderRelays = new Set<string>();
|
|
120
|
+
const rememberSurrenderRelay = (id: string): void => {
|
|
121
|
+
if (pendingSurrenderRelays.size >= 256) {
|
|
122
|
+
const oldest = pendingSurrenderRelays.values().next().value;
|
|
123
|
+
if (oldest !== undefined) pendingSurrenderRelays.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
pendingSurrenderRelays.add(id);
|
|
126
|
+
};
|
|
116
127
|
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
117
128
|
pi.on("session_start", async (_event, ctx) => {
|
|
129
|
+
pendingSurrenderRelays.clear();
|
|
118
130
|
h.lastCtx = ctx;
|
|
119
131
|
h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
|
|
120
132
|
h.childUsage.reset();
|
|
@@ -174,29 +186,48 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
174
186
|
const sessionId = sessionManager?.getSessionId?.();
|
|
175
187
|
if (sessionId) {
|
|
176
188
|
const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
try {
|
|
190
|
+
h.telemetry = new TelemetryProducer({
|
|
191
|
+
agentDir: h.userAgentDir(),
|
|
192
|
+
cwd: ctx.cwd,
|
|
193
|
+
sessionId,
|
|
194
|
+
emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
|
|
195
|
+
heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
|
|
196
|
+
onError: (error: unknown) => {
|
|
197
|
+
// Retention and writer failures must be observable during normal operation too.
|
|
198
|
+
// A broken UI/logger must never turn best-effort telemetry into a session failure.
|
|
199
|
+
try {
|
|
200
|
+
const message = `pi-persona: telemetry: ${compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 400 })}`;
|
|
201
|
+
const current = h.lastCtx ?? ctx;
|
|
202
|
+
if (current.hasUI) current.ui.notify(message, "warning");
|
|
203
|
+
else process.stderr.write(`${message}\n`);
|
|
204
|
+
} catch { /* diagnostics are non-fatal */ }
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
h.telemetry.start(exocom.currentTelemetryInstance(ctx));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
h.telemetry = undefined;
|
|
210
|
+
const message = "pi-persona: telemetry unavailable (safe session admission failed)";
|
|
211
|
+
try {
|
|
212
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
213
|
+
else process.stderr.write(`${message}\n`);
|
|
214
|
+
} catch { /* admission diagnostics are non-fatal too */ }
|
|
215
|
+
}
|
|
216
|
+
if (h.telemetry) {
|
|
217
|
+
// Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
|
|
218
|
+
// published — a session start that follows no teardown carries no stale projection.
|
|
219
|
+
h.lastAgentProjection.clear();
|
|
220
|
+
for (const node of h.agentTree.snapshot()) {
|
|
221
|
+
h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
|
|
222
|
+
h.telemetry.publishAgentAdded(h.telemetryAgent(node));
|
|
223
|
+
}
|
|
224
|
+
exocom.publishTelemetryPeers();
|
|
194
225
|
}
|
|
195
|
-
exocom.publishTelemetryPeers();
|
|
196
226
|
}
|
|
197
227
|
});
|
|
198
228
|
|
|
199
229
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
230
|
+
pendingSurrenderRelays.clear();
|
|
200
231
|
h.lastCtx = ctx;
|
|
201
232
|
h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
|
|
202
233
|
h.deferredOrchestrations.length = 0;
|
|
@@ -246,6 +277,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
246
277
|
// idle window first; its replay starts the next run and the remaining FIFO entries follow one per
|
|
247
278
|
// settlement. With no deferred input, flush ordinary wakes immediately.
|
|
248
279
|
pi.on("agent_settled", async () => {
|
|
280
|
+
pendingSurrenderRelays.clear();
|
|
249
281
|
if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
|
|
250
282
|
// Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
|
|
251
283
|
// after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
|
|
@@ -625,16 +657,31 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
625
657
|
if (!h.config.nudge) return errorPatch;
|
|
626
658
|
// Only a supervisor that CAN delegate is nudged to — a persona without the tool can't act on it.
|
|
627
659
|
if (!h.controller.capabilities?.tools.has("delegate")) return errorPatch;
|
|
660
|
+
const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
|
|
661
|
+
const relayed = pendingSurrenderRelays.delete(event.toolCallId);
|
|
662
|
+
const surrender = relayed ? h.persistenceNudge.scan(text) : h.persistenceNudge.observe(event.toolName, text);
|
|
663
|
+
// A NESTED call (a codemode script's `ctx.executeTool`) is a real execution — the gate, the
|
|
664
|
+
// telemetry above and the error patch all already ran — but its content is not a model-facing
|
|
665
|
+
// tool result. The caller chooses what to print or discard; only that outer output is charged
|
|
666
|
+
// to the context-burn run. Appending a note here would modify programmatic data and could
|
|
667
|
+
// create an invisible reminder. `parentToolCallId` is the host's explicit marker; `<parent>/<n>`
|
|
668
|
+
// id shape is never inferred from the id string.
|
|
669
|
+
if (typeof event.parentToolCallId === "string" && event.parentToolCallId.length > 0) {
|
|
670
|
+
if (surrender) rememberSurrenderRelay(event.parentToolCallId);
|
|
671
|
+
// A hand-off that actually landed ends the run even when a script issued it, so the reset is
|
|
672
|
+
// NOT dropped here. A FAILED one does not reset, and its repair note would be invisible:
|
|
673
|
+
// the next model-visible result is what reports the streak.
|
|
674
|
+
if (!errorPatch && event.isError !== true && HANDOFF_TOOL_NAMES.has(event.toolName)) h.delegationNudge.reset();
|
|
675
|
+
return errorPatch;
|
|
676
|
+
}
|
|
628
677
|
const notes: string[] = [];
|
|
629
678
|
// Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
|
|
630
679
|
// tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
|
|
631
|
-
const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
|
|
632
680
|
const size = text.length;
|
|
633
681
|
const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
|
|
634
682
|
if (sweepNote) notes.push(sweepNote);
|
|
635
683
|
// Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
|
|
636
684
|
// results only; because delegate/council reset the run the two never fire on one event).
|
|
637
|
-
const surrender = h.persistenceNudge.observe(event.toolName, text);
|
|
638
685
|
if (surrender) notes.push(surrender);
|
|
639
686
|
if (notes.length === 0) return errorPatch;
|
|
640
687
|
const joined = notes.join("\n\n");
|