@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.
Files changed (44) hide show
  1. package/AGENTS.md +32 -6
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +14 -0
  11. package/package.json +18 -11
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +233 -57
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
@@ -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
- modelFor?: (agent: string) => string | undefined;
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 ref = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model ?? deps.defaultModel;
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" : deps.modelFor?.(spec.agent) ? "agent picker" : cfg.model ? "agent config" : "default";
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
- onSteerable?.((text) => {
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
@@ -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 = (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, engOpts?: { async?: boolean }) => StrategyEngine;
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
- const modelFor = (agent: string): string | undefined => {
67
- const persona = controller.activePersona?.name;
68
- return persona ? personaModels(personaConfigs, persona)[agent] : undefined;
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, {
@@ -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 { DelegationNudge, PersistenceNudge } from "../core/nudge.ts";
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
- h.telemetry = new TelemetryProducer({
178
- agentDir: h.userAgentDir(),
179
- cwd: ctx.cwd,
180
- sessionId,
181
- emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
182
- heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
183
- ...(process.env.PI_PERSONA_DEBUG
184
- ? { onError: (error: unknown) => { process.stderr.write(`[pi-persona] telemetry: ${error instanceof Error ? error.message : String(error)}\n`); } }
185
- : {}),
186
- });
187
- h.telemetry.start(exocom.currentTelemetryInstance(ctx));
188
- // Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
189
- // published — a session start that follows no teardown carries no stale projection.
190
- h.lastAgentProjection.clear();
191
- for (const node of h.agentTree.snapshot()) {
192
- h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
193
- h.telemetry.publishAgentAdded(h.telemetryAgent(node));
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");