@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Participant-aware model resolution for an orchestrated run.
|
|
3
|
+
*
|
|
4
|
+
* A strategy's participants are NOT just its roster members: an arbiter (`params.judge`), a
|
|
5
|
+
* synthesiser, a critic or a generator is a real participant too — it spawns, it is billed, and its
|
|
6
|
+
* model matters exactly as much as a core's. This module is the ONE pure place that answers two
|
|
7
|
+
* questions for the UI layer:
|
|
8
|
+
*
|
|
9
|
+
* 1. WHO runs — roster members plus every auxiliary actor declared by an agent-typed strategy
|
|
10
|
+
* param (metadata-driven, so a new strategy needs no name switch here).
|
|
11
|
+
* 2. ON WHAT — the effective model by precedence:
|
|
12
|
+
* explicit inline member model
|
|
13
|
+
* > saved per-persona assignment for the ROLE key
|
|
14
|
+
* > saved per-persona assignment for the agent key
|
|
15
|
+
* > the agent's own frontmatter model
|
|
16
|
+
* > the session model (the default, NOT an assignment — it must not suppress the picker).
|
|
17
|
+
*
|
|
18
|
+
* The persisted assignment KEY is `agent` for a bare member — the historical shape, so every
|
|
19
|
+
* existing `config.json` keeps applying — and `agent#<hash-of-agent+role>` for a member carrying a
|
|
20
|
+
* role. The hash keeps the full role as the identity without persisting the role PROMPT (which is
|
|
21
|
+
* an instruction, not a key, and would leak strategy prose into the user's config file). The human
|
|
22
|
+
* label stays separate, so the picker and the tree can still say `reviewer · SECURITY`.
|
|
23
|
+
*
|
|
24
|
+
* Pure: no engine, no UI, no config store, no clock. The picker decides; this decides WHAT it may
|
|
25
|
+
* ask about.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
30
|
+
import { memberBaseLabel, parseRosterMember, type RosterMember, type RosterSpec } from "../orchestration/roster.ts";
|
|
31
|
+
import { knownParams } from "../orchestration/strategy.ts";
|
|
32
|
+
import type { StrategyParam } from "../orchestration/sdk.ts";
|
|
33
|
+
|
|
34
|
+
/** Where a participant's effective model came from — in precedence order. */
|
|
35
|
+
export type ModelSource = "inline" | "assignment" | "agent" | "session" | "none";
|
|
36
|
+
|
|
37
|
+
/** A strategy param that names a real participant (an arbiter, a synthesiser, …) is marked
|
|
38
|
+
* `type: "agent"` in the strategy's own metadata — the single declaration, read from
|
|
39
|
+
* `knownParams`, so adding a strategy never means editing this file. */
|
|
40
|
+
function isAgentParam(param: StrategyParam): boolean {
|
|
41
|
+
return param.type === "agent";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ModelParticipant {
|
|
45
|
+
/** The agent that runs. */
|
|
46
|
+
agent: string;
|
|
47
|
+
/** The on-the-fly role prompt, when the participant carries one (never persisted in `key`). */
|
|
48
|
+
role?: string;
|
|
49
|
+
/** Stable model-assignment key: the agent name, or `agent#<hash>` for a role member. */
|
|
50
|
+
key: string;
|
|
51
|
+
/** What a human should see: `agent`, or `agent · HINT` for a role member. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** A roster member, or an auxiliary actor declared by a strategy param. */
|
|
54
|
+
origin: "roster" | "param";
|
|
55
|
+
/** The strategy param that declares this auxiliary actor (roster members omit it). */
|
|
56
|
+
param?: string;
|
|
57
|
+
/** The effective model for this participant, by precedence; undefined when nothing supplies one. */
|
|
58
|
+
model?: string;
|
|
59
|
+
/** Which step of the precedence supplied `model`. */
|
|
60
|
+
source: ModelSource;
|
|
61
|
+
/** Someone already chose this participant's model (inline, a saved assignment, or the agent's own
|
|
62
|
+
* frontmatter) ⇒ never a picker candidate. The session default is deliberately NOT counted. */
|
|
63
|
+
assigned: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ParticipantInput {
|
|
67
|
+
/** The run's members (the caller resolves inline-over-roster via
|
|
68
|
+
* `resolveOrchestrationMembers`). */
|
|
69
|
+
members?: RosterMember[];
|
|
70
|
+
/** The running strategy — used to look up its declared agent-typed params. */
|
|
71
|
+
strategy?: string;
|
|
72
|
+
/** The strategy's params, as the run will see them. */
|
|
73
|
+
params?: Record<string, unknown>;
|
|
74
|
+
/** Param schema override; defaults to `knownParams(strategy)`. Test/forward seam. */
|
|
75
|
+
schema?: Record<string, StrategyParam>;
|
|
76
|
+
/** The persona's saved assignments, keyed by `participantKey`. */
|
|
77
|
+
assigned?: Record<string, string>;
|
|
78
|
+
/** An agent's own declared model (frontmatter). */
|
|
79
|
+
agentModel?: (agent: string) => string | undefined;
|
|
80
|
+
/** The session's own model — the last-resort default. */
|
|
81
|
+
sessionModel?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The digest behind a role key. SHA-256 (128 bits kept) rather than a short rolling hash: this
|
|
86
|
+
* value is the identity a model assignment is filed under, so a collision would hand one lens
|
|
87
|
+
* another's model — a silent, expensive wrong answer. `crypto` is a node builtin (no dependency,
|
|
88
|
+
* no host package), and the digest is deterministic across processes and runs, which is what lets
|
|
89
|
+
* a persisted key resolve to the same participant tomorrow.
|
|
90
|
+
*/
|
|
91
|
+
function stableDigest(value: string): string {
|
|
92
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The model-assignment key for a participant. A bare member (no role) keeps the agent name — the
|
|
97
|
+
* pre-role key shape, so assignments saved before roles existed keep applying to it.
|
|
98
|
+
*/
|
|
99
|
+
export function participantKey(agent: string, role?: string): string {
|
|
100
|
+
const trimmed = role?.trim();
|
|
101
|
+
return trimmed ? `${agent}#${stableDigest(`${agent}\0${trimmed}`)}` : agent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One participant's model, resolved in the one precedence order the UI and the engines share. */
|
|
105
|
+
function fromSpec(spec: RosterSpec, origin: "roster" | "param", param: string | undefined, input: ParticipantInput): ModelParticipant {
|
|
106
|
+
const assigned = input.assigned ?? {};
|
|
107
|
+
const key = participantKey(spec.agent, spec.role);
|
|
108
|
+
let model: string | undefined;
|
|
109
|
+
let source: ModelSource = "none";
|
|
110
|
+
if (spec.model) {
|
|
111
|
+
model = spec.model;
|
|
112
|
+
source = "inline";
|
|
113
|
+
} else if (assigned[key]) {
|
|
114
|
+
model = assigned[key];
|
|
115
|
+
source = "assignment";
|
|
116
|
+
} else if (assigned[spec.agent]) {
|
|
117
|
+
model = assigned[spec.agent];
|
|
118
|
+
source = "assignment";
|
|
119
|
+
} else {
|
|
120
|
+
const agentModel = input.agentModel?.(spec.agent);
|
|
121
|
+
if (agentModel) {
|
|
122
|
+
model = agentModel;
|
|
123
|
+
source = "agent";
|
|
124
|
+
} else if (input.sessionModel) {
|
|
125
|
+
model = input.sessionModel;
|
|
126
|
+
source = "session";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
agent: spec.agent,
|
|
131
|
+
...(spec.role ? { role: spec.role } : {}),
|
|
132
|
+
key,
|
|
133
|
+
label: memberBaseLabel(asMember(spec)),
|
|
134
|
+
origin,
|
|
135
|
+
...(param ? { param } : {}),
|
|
136
|
+
...(model ? { model } : {}),
|
|
137
|
+
source,
|
|
138
|
+
assigned: source === "inline" || source === "assignment" || source === "agent",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Rebuild the member shape `memberBaseLabel` normalises (it takes a RosterMember, not a spec). */
|
|
143
|
+
function asMember(spec: RosterSpec): RosterMember {
|
|
144
|
+
return spec.role ? { agent: spec.agent, role: spec.role, ...(spec.model ? { model: spec.model } : {}) } : spec.agent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** An auxiliary actor resolves in ONE chain: the value the caller supplied → (only where the
|
|
148
|
+
* metadata says so) the roster member it names or points at → the real agent named by `default`.
|
|
149
|
+
* A default that is a SENTENCE rather than an agent ("the first roster agent") is no participant
|
|
150
|
+
* at all — taking a phrase literally would offer a model for a leg that can never run. */
|
|
151
|
+
function auxActorSpec(input: ParticipantInput, paramName: string, param: StrategyParam): RosterSpec | undefined {
|
|
152
|
+
const supplied = input.params?.[paramName];
|
|
153
|
+
const spec = toSpec(supplied);
|
|
154
|
+
if (spec) {
|
|
155
|
+
// `inheritRoster` is opt-in per param (e.g. `map.verify`'s documented "a member named X"
|
|
156
|
+
// lookup) and only ever rewrites a BARE selector. An arbiter/critic keeps its historic
|
|
157
|
+
// meaning — naming that agent IS the actor, whatever role a panel member happens to wear.
|
|
158
|
+
if (param.inheritRoster && typeof supplied === "string") {
|
|
159
|
+
const seated = rosterMemberNamed(input.members, spec.agent);
|
|
160
|
+
if (seated) return seated;
|
|
161
|
+
}
|
|
162
|
+
return spec;
|
|
163
|
+
}
|
|
164
|
+
const index = rosterIndexOf(param);
|
|
165
|
+
if (index !== undefined) {
|
|
166
|
+
const seated = toSpec(input.members?.[index]);
|
|
167
|
+
if (seated) return seated;
|
|
168
|
+
}
|
|
169
|
+
return isAgentName(param.default) ? toSpec(param.default) : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The roster entry an aux selector names, verbatim — its role/inline model included, so the aux
|
|
173
|
+
* actor and the panel entry stay ONE participant with ONE model key. */
|
|
174
|
+
function rosterMemberNamed(members: RosterMember[] | undefined, agent: string): RosterSpec | undefined {
|
|
175
|
+
for (const member of members ?? []) {
|
|
176
|
+
const spec = toSpec(member);
|
|
177
|
+
if (spec?.agent === agent) return spec;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The roster position an agent-typed param defaults to, when its metadata declares one. */
|
|
183
|
+
function rosterIndexOf(param: StrategyParam): number | undefined {
|
|
184
|
+
const index = param.rosterIndex;
|
|
185
|
+
return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Is this default an AGENT NAME (one token) rather than a documentation phrase? Agent names never
|
|
189
|
+
* contain whitespace, so a multi-word default is prose describing a default, not a participant. */
|
|
190
|
+
function isAgentName(value: unknown): boolean {
|
|
191
|
+
return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value.trim());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Every participant a run really spawns: the roster first (in run order), then auxiliary actors. */
|
|
195
|
+
export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
|
|
196
|
+
const out: ModelParticipant[] = [];
|
|
197
|
+
const seen = new Set<string>();
|
|
198
|
+
for (const raw of input.members ?? []) {
|
|
199
|
+
const spec = toSpec(raw);
|
|
200
|
+
if (!spec) continue;
|
|
201
|
+
const p = fromSpec(spec, "roster", undefined, input);
|
|
202
|
+
if (seen.has(p.key)) continue;
|
|
203
|
+
seen.add(p.key);
|
|
204
|
+
out.push(p);
|
|
205
|
+
}
|
|
206
|
+
const schema = input.schema ?? (input.strategy ? knownParams(input.strategy) : undefined);
|
|
207
|
+
if (schema) {
|
|
208
|
+
for (const [paramName, param] of Object.entries(schema)) {
|
|
209
|
+
if (!isAgentParam(param)) continue;
|
|
210
|
+
const spec = auxActorSpec(input, paramName, param);
|
|
211
|
+
if (!spec) continue;
|
|
212
|
+
const p = fromSpec(spec, "param", paramName, input);
|
|
213
|
+
if (seen.has(p.key)) continue;
|
|
214
|
+
seen.add(p.key);
|
|
215
|
+
out.push(p);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The participants a picker may still ask about: nobody has chosen a model for them, and this
|
|
222
|
+
* session has not already asked (and been dismissed) for that key. The caller records the keys it
|
|
223
|
+
* asks about BEFORE asking, so a dismissal is remembered too. */
|
|
224
|
+
export function pendingParticipants(
|
|
225
|
+
participants: readonly ModelParticipant[],
|
|
226
|
+
opts: { prompted: ReadonlySet<string> },
|
|
227
|
+
): ModelParticipant[] {
|
|
228
|
+
return participants.filter((p) => !p.assigned && !opts.prompted.has(p.key));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A roster member / param value is either a bare agent name or an inline specialisation map. Both
|
|
232
|
+
// come from the shared roster grammar (`parseRosterMember`), so an auxiliary actor named inline in
|
|
233
|
+
// a `params.judge` entry is normalised exactly like a `teams.yaml` member.
|
|
234
|
+
function toSpec(value: unknown): RosterSpec | undefined {
|
|
235
|
+
if (value === undefined || value === null || typeof value === "boolean" || typeof value === "number") return undefined;
|
|
236
|
+
const member = parseRosterMember(value);
|
|
237
|
+
if (!member) return undefined;
|
|
238
|
+
const spec = typeof member === "string" ? { agent: member } : member;
|
|
239
|
+
return spec.agent.trim() ? spec : undefined;
|
|
240
|
+
}
|
|
@@ -31,6 +31,31 @@ export function resolveStrategyName(orch: OrchestrationGrammar): string | undefi
|
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The reserved team key under which an EPHEMERAL `members:` list is registered for one run.
|
|
35
|
+
* A CLONE of the shared team map carries it, so no team file, discovery map, or later call
|
|
36
|
+
* ever sees it — the reserved key is a per-call implementation detail, not a team name. */
|
|
37
|
+
export const ADHOC_MEMBERS_KEY = "__adhoc_members__";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The members a run actually uses: inline `orch.members` when present (they WIN the named
|
|
41
|
+
* team — ephemeral, one call, nothing written back), else the named `orch.roster`'s members.
|
|
42
|
+
* An unknown team name is a diagnostic naming the known teams, raised BEFORE any engine call.
|
|
43
|
+
* No roster at all ⇒ `[]`; whether that is fatal is the strategy's own call.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveOrchestrationMembers(
|
|
46
|
+
orch: Pick<OrchestrationGrammar, "roster" | "members">,
|
|
47
|
+
teams: Record<string, RosterMember[]>,
|
|
48
|
+
): RosterMember[] {
|
|
49
|
+
if (orch.members && orch.members.length > 0) return orch.members;
|
|
50
|
+
if (!orch.roster) return [];
|
|
51
|
+
const members = teams[orch.roster];
|
|
52
|
+
if (!members) {
|
|
53
|
+
const known = Object.keys(teams).sort();
|
|
54
|
+
throw new Error(`unknown roster "${orch.roster}" (${known.length > 0 ? `available teams: ${known.join(", ")}` : "no teams are defined"})`);
|
|
55
|
+
}
|
|
56
|
+
return members;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
export interface RunStrategyDeps {
|
|
35
60
|
engine: StrategyEngine;
|
|
36
61
|
teams: Record<string, RosterMember[]>;
|
|
@@ -39,6 +64,11 @@ export interface RunStrategyDeps {
|
|
|
39
64
|
/** The session's own model (`provider/id`) — a strategy's last-resort recovery model for a
|
|
40
65
|
* member whose own model broke with no healthy peer to borrow from. */
|
|
41
66
|
sessionModel?: string;
|
|
67
|
+
/** The session model read AT USE TIME (the session can switch models mid-run), forwarded
|
|
68
|
+
* beside the static `sessionModel` so a late leg still sees the current one. */
|
|
69
|
+
getSessionModel?: () => string | undefined;
|
|
70
|
+
/** A provider reroute was applied to one leg (the fallback layer's telemetry hook). */
|
|
71
|
+
onModelFallback?: (info: { agent: string; from?: string; to: string; key: string }) => void;
|
|
42
72
|
log?: (message: string) => void;
|
|
43
73
|
/** Per-agent lifecycle, for live UI (which roster agent is running/done + its result).
|
|
44
74
|
* `key` is a run-unique display id (disambiguates same-agent roster-role members). */
|
|
@@ -66,9 +96,18 @@ export async function runPersonaStrategy(
|
|
|
66
96
|
// so the council/flow surfaces it instead of an opaque "no ruling".
|
|
67
97
|
if (!strategy) throw new Error(`unknown strategy "${name}" (available: ${strategyNames().join(", ")})`);
|
|
68
98
|
|
|
69
|
-
|
|
99
|
+
// Ephemeral members ride a CLONE of the shared team map under a reserved key: the
|
|
100
|
+
// strategies read members by team name, so this is the one seam that lets an ad-hoc
|
|
101
|
+
// council run through the exact same path — without ever mutating `deps.teams`.
|
|
102
|
+
// An unknown NAMED roster is still left to the strategy (which fails with its own
|
|
103
|
+
// "a roster of … is required" before any engine call); only inline members change here.
|
|
104
|
+
const adhocMembers = orch.members && orch.members.length > 0 ? orch.members : undefined;
|
|
105
|
+
const teams = adhocMembers ? { ...deps.teams, [ADHOC_MEMBERS_KEY]: adhocMembers } : deps.teams;
|
|
106
|
+
const sdkDeps: SDKDeps = { engine: deps.engine, roster: makeRoster(teams), limits: deps.limits };
|
|
70
107
|
if (deps.signal) sdkDeps.signal = deps.signal;
|
|
71
108
|
if (deps.sessionModel) sdkDeps.sessionModel = deps.sessionModel;
|
|
109
|
+
if (deps.getSessionModel) sdkDeps.getSessionModel = deps.getSessionModel;
|
|
110
|
+
if (deps.onModelFallback) sdkDeps.onModelFallback = deps.onModelFallback;
|
|
72
111
|
if (deps.log) sdkDeps.log = deps.log;
|
|
73
112
|
if (deps.onAgentStatus) sdkDeps.onAgentStatus = deps.onAgentStatus;
|
|
74
113
|
if (deps.onAgentProgress) sdkDeps.onAgentProgress = deps.onAgentProgress;
|
|
@@ -77,7 +116,10 @@ export async function runPersonaStrategy(
|
|
|
77
116
|
if (deps.canSpawn) sdkDeps.canSpawn = deps.canSpawn;
|
|
78
117
|
|
|
79
118
|
const input: StrategyInput = { task, params: orch.params ?? {} };
|
|
80
|
-
|
|
119
|
+
// Inline members win the named team for this call (`resolveOrchestrationMembers` is the
|
|
120
|
+
// shared read path for callers that need the roster resolved up front — the tree seeding).
|
|
121
|
+
if (adhocMembers) input.roster = ADHOC_MEMBERS_KEY;
|
|
122
|
+
else if (orch.roster) input.roster = orch.roster;
|
|
81
123
|
|
|
82
124
|
return strategy.run(input, makeSDK(sdkDeps));
|
|
83
125
|
}
|
package/src/persona/persona.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { asBoolean, asPermission, asStringArray, parseYamlSubset, splitFrontmatter } from "../core/frontmatter.ts";
|
|
11
11
|
import type { Permission } from "../core/permissions.ts";
|
|
12
12
|
import { asSystemPromptMode, type SystemPromptMode } from "../core/types.ts";
|
|
13
|
+
import { parseRuntimeRosterMember, type RosterMember } from "../orchestration/roster.ts";
|
|
13
14
|
|
|
14
15
|
export type OrchestrationMode = "solo" | "parallel" | "pipeline" | "strategy" | "flow";
|
|
15
16
|
const ORCHESTRATION_MODES: readonly OrchestrationMode[] = ["solo", "parallel", "pipeline", "strategy", "flow"];
|
|
@@ -20,6 +21,9 @@ export interface OrchestrationGrammar {
|
|
|
20
21
|
strategy?: string;
|
|
21
22
|
flow?: string;
|
|
22
23
|
roster?: string;
|
|
24
|
+
/** Ephemeral members for THIS run — validated inline specialisations that win `roster`
|
|
25
|
+
* (see `resolveOrchestrationMembers`). Never written back to a team or persona file. */
|
|
26
|
+
members?: RosterMember[];
|
|
23
27
|
/** Strategy parameters (e.g. rounds, aggregate, critic). */
|
|
24
28
|
params?: Record<string, unknown>;
|
|
25
29
|
}
|
|
@@ -41,6 +45,13 @@ export interface CouncilSpec {
|
|
|
41
45
|
export interface CouncilDraft {
|
|
42
46
|
strategy?: string;
|
|
43
47
|
roster?: string;
|
|
48
|
+
/** Ephemeral members declared inline (bare agent names or `{ agent, … }` maps), validated
|
|
49
|
+
* at parse. They win `roster` for the calls this persona convenes. */
|
|
50
|
+
members?: RosterMember[];
|
|
51
|
+
/** Set when a `members:` block was AUTHORED but unusable (not a list, empty, or an entry
|
|
52
|
+
* that is not a member). Kept as a diagnostic so the call fails loudly instead of
|
|
53
|
+
* quietly convening some other roster. */
|
|
54
|
+
membersProblem?: string;
|
|
44
55
|
params?: Record<string, unknown>;
|
|
45
56
|
/** A named preset (presets/<name>.preset.json) providing defaults; authored fields override. */
|
|
46
57
|
preset?: string;
|
|
@@ -165,23 +176,51 @@ function parseDelegationPolicy(value: unknown): DelegationPolicy | undefined {
|
|
|
165
176
|
return Object.keys(policy).length > 0 ? policy : undefined;
|
|
166
177
|
}
|
|
167
178
|
|
|
168
|
-
/** Parse a persona's `council:` block (strategy + roster + params, or just a `preset`). */
|
|
179
|
+
/** Parse a persona's `council:` block (strategy + roster + members + params, or just a `preset`). */
|
|
169
180
|
function parseCouncil(value: unknown): CouncilDraft | undefined {
|
|
170
181
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
171
182
|
const o = value as Record<string, unknown>;
|
|
172
183
|
const strategy = typeof o.strategy === "string" && o.strategy.trim() ? o.strategy.trim() : "";
|
|
173
184
|
const preset = typeof o.preset === "string" && o.preset.trim() ? o.preset.trim() : "";
|
|
174
|
-
|
|
185
|
+
// Validate `members:` BEFORE the a-council-needs-a-strategy gate: a members-only block
|
|
186
|
+
// with a broken list is a broken declaration, and returning undefined here would drop the
|
|
187
|
+
// diagnostic and let the call quietly convene the MAGI fallback instead.
|
|
188
|
+
const parsedMembers = Object.hasOwn(o, "members") ? parseMembersBlock(o.members) : undefined;
|
|
189
|
+
if (!strategy && !preset && !parsedMembers) return undefined;
|
|
175
190
|
const spec: CouncilDraft = {};
|
|
176
191
|
if (strategy) spec.strategy = strategy;
|
|
177
192
|
if (preset) spec.preset = preset;
|
|
178
193
|
if (typeof o.roster === "string" && o.roster.trim()) spec.roster = o.roster.trim();
|
|
194
|
+
if (parsedMembers) {
|
|
195
|
+
if (parsedMembers.ok) spec.members = parsedMembers.members;
|
|
196
|
+
else spec.membersProblem = parsedMembers.error;
|
|
197
|
+
}
|
|
179
198
|
if (o.params && typeof o.params === "object" && !Array.isArray(o.params)) {
|
|
180
199
|
spec.params = o.params as Record<string, unknown>;
|
|
181
200
|
}
|
|
182
201
|
return spec;
|
|
183
202
|
}
|
|
184
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Validate an authored `members:` block into roster members. A block that was explicitly
|
|
206
|
+
* supplied but is not a usable list is an ERROR, never a silent absence: dropping it would
|
|
207
|
+
* fall back to the persona's named team (or MAGI) and present an unrelated ensemble as the
|
|
208
|
+
* one the author asked for. Members are validated with the STRICT runtime parser, so a
|
|
209
|
+
* malformed specialisation field (`tools: ["read", 42]`, `model: 42`) is a diagnostic rather
|
|
210
|
+
* than a silently dropped field that widens or narrows what the leg may do.
|
|
211
|
+
*/
|
|
212
|
+
export function parseMembersBlock(value: unknown): { ok: true; members: RosterMember[] } | { ok: false; error: string } {
|
|
213
|
+
if (!Array.isArray(value)) return { ok: false, error: "council.members must be a list of agent names or { agent, … } members" };
|
|
214
|
+
if (value.length === 0) return { ok: false, error: "council.members is empty — declare at least one member or drop the key" };
|
|
215
|
+
const members: RosterMember[] = [];
|
|
216
|
+
for (const [i, raw] of value.entries()) {
|
|
217
|
+
const parsed = parseRuntimeRosterMember(raw);
|
|
218
|
+
if (!parsed.ok) return { ok: false, error: `council.members[${i}]: ${parsed.error}` };
|
|
219
|
+
members.push(parsed.member);
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, members };
|
|
222
|
+
}
|
|
223
|
+
|
|
185
224
|
/** Expand a council `preset` (presets/<name>.preset.json) into concrete fields: the preset
|
|
186
225
|
* supplies defaults, authored fields win, and `params` shallow-merge (authored over preset).
|
|
187
226
|
* The `preset` key is consumed. An unknown preset just drops the key (authored fields kept). */
|
|
@@ -190,8 +229,14 @@ export function expandCouncilPreset(draft: CouncilDraft, presets: Record<string,
|
|
|
190
229
|
const base = presets[draft.preset];
|
|
191
230
|
const { preset: _consumed, ...authored } = draft;
|
|
192
231
|
if (!base) return authored;
|
|
193
|
-
|
|
194
|
-
|
|
232
|
+
// Presets only declare strategy, roster, and params. Copy that allowlist instead of
|
|
233
|
+
// spreading a runtime object, so an extra `members` key cannot bypass parseMembersBlock.
|
|
234
|
+
const defaults: CouncilDraft = {};
|
|
235
|
+
if (typeof base.strategy === "string" && base.strategy.trim()) defaults.strategy = base.strategy.trim();
|
|
236
|
+
if (typeof base.roster === "string" && base.roster.trim()) defaults.roster = base.roster.trim();
|
|
237
|
+
if (base.params && typeof base.params === "object" && !Array.isArray(base.params)) defaults.params = base.params;
|
|
238
|
+
const merged: CouncilDraft = { ...defaults, ...authored };
|
|
239
|
+
if (defaults.params || authored.params) merged.params = { ...defaults.params, ...authored.params };
|
|
195
240
|
return merged;
|
|
196
241
|
}
|
|
197
242
|
|
|
@@ -203,12 +248,19 @@ export interface CouncilInvocation {
|
|
|
203
248
|
persona?: string | undefined;
|
|
204
249
|
strategy?: string | undefined;
|
|
205
250
|
roster?: string | undefined;
|
|
251
|
+
/** Raw `members:` from the call — validated HERE so an unusable block is a diagnostic
|
|
252
|
+
* rather than a silent fall back to the named team (a tool schema cannot validate it). */
|
|
253
|
+
members?: unknown;
|
|
206
254
|
params?: Record<string, unknown> | undefined;
|
|
207
255
|
}
|
|
208
256
|
|
|
209
257
|
export interface ResolvedCouncilInvocation {
|
|
210
258
|
strategy: string;
|
|
259
|
+
/** The named team. EMPTY when inline `members` are in effect — there is no named team for
|
|
260
|
+
* an ephemeral call, and a misleading team name would mislabel the card and the tree. */
|
|
211
261
|
roster: string;
|
|
262
|
+
/** The ephemeral members actually in effect (validated), when any. */
|
|
263
|
+
members?: RosterMember[];
|
|
212
264
|
params: Record<string, unknown>;
|
|
213
265
|
/** Persona whose council declaration supplied the defaults, when any. */
|
|
214
266
|
persona?: string;
|
|
@@ -218,6 +270,10 @@ export type CouncilInvocationResolution =
|
|
|
218
270
|
| { ok: true; value: ResolvedCouncilInvocation }
|
|
219
271
|
| { ok: false; error: string };
|
|
220
272
|
|
|
273
|
+
function hasCouncilDeclaration(council: CouncilDraft | undefined): boolean {
|
|
274
|
+
return Boolean(council?.strategy || council?.members || council?.membersProblem);
|
|
275
|
+
}
|
|
276
|
+
|
|
221
277
|
/** Resolve a council call without mutating the active persona.
|
|
222
278
|
*
|
|
223
279
|
* An explicit persona is intentionally strict: it must exist and declare a
|
|
@@ -236,7 +292,7 @@ export function resolveCouncilInvocation(
|
|
|
236
292
|
sourcePersona = personas.find((persona) => persona.isPersona && persona.name === requestedName);
|
|
237
293
|
if (!sourcePersona) {
|
|
238
294
|
const available = personas
|
|
239
|
-
.filter((persona) => persona.isPersona &&
|
|
295
|
+
.filter((persona) => persona.isPersona && hasCouncilDeclaration(persona.council))
|
|
240
296
|
.map((persona) => persona.name)
|
|
241
297
|
.sort();
|
|
242
298
|
return {
|
|
@@ -244,30 +300,46 @@ export function resolveCouncilInvocation(
|
|
|
244
300
|
error: `no persona named "${requestedName}". Council personas: ${available.join(", ") || "(none)"}`,
|
|
245
301
|
};
|
|
246
302
|
}
|
|
247
|
-
if (!sourcePersona.council
|
|
303
|
+
if (!hasCouncilDeclaration(sourcePersona.council)) {
|
|
248
304
|
return {
|
|
249
305
|
ok: false,
|
|
250
306
|
error: `persona "${requestedName}" declares no usable council`,
|
|
251
307
|
};
|
|
252
308
|
}
|
|
253
|
-
} else if (activePersona?.council
|
|
309
|
+
} else if (hasCouncilDeclaration(activePersona?.council)) {
|
|
254
310
|
sourcePersona = activePersona;
|
|
255
311
|
}
|
|
256
312
|
|
|
257
313
|
const base = sourcePersona?.council;
|
|
314
|
+
if (base?.membersProblem) {
|
|
315
|
+
return { ok: false, error: `${requestedName ? `persona "${requestedName}"` : `the active persona "${activePersona?.name ?? "?"}"`}: ${base.membersProblem}` };
|
|
316
|
+
}
|
|
258
317
|
const strategy = request.strategy?.trim() || base?.strategy || "magi";
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
318
|
+
// Ephemeral members win the named team, for this call only: nothing is written back to
|
|
319
|
+
// teams.yaml, the persona file, or any model/prompt/capability the profile would imply.
|
|
320
|
+
let members = base?.members;
|
|
321
|
+
if (request.members !== undefined) {
|
|
322
|
+
const parsed = parseMembersBlock(request.members);
|
|
323
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
324
|
+
members = parsed.members;
|
|
325
|
+
}
|
|
326
|
+
const roster = members
|
|
327
|
+
? ""
|
|
328
|
+
: request.roster?.trim() ||
|
|
329
|
+
base?.roster ||
|
|
330
|
+
sourcePersona?.orchestration?.roster ||
|
|
331
|
+
(!requestedName ? activePersona?.orchestration?.roster : undefined) ||
|
|
332
|
+
"magi";
|
|
333
|
+
// Profile params belong to the profile's strategy. A per-call strategy switch starts
|
|
334
|
+
// with that strategy's own defaults; only explicitly supplied params cross the switch.
|
|
335
|
+
const inheritedParams = strategy !== (base?.strategy || "magi") ? {} : base?.params ?? {};
|
|
265
336
|
const value: ResolvedCouncilInvocation = {
|
|
266
337
|
strategy,
|
|
267
338
|
roster,
|
|
268
|
-
params: { ...
|
|
339
|
+
params: { ...inheritedParams, ...(request.params ?? {}) },
|
|
269
340
|
};
|
|
270
341
|
if (sourcePersona) value.persona = sourcePersona.name;
|
|
342
|
+
if (members) value.members = members;
|
|
271
343
|
return { ok: true, value };
|
|
272
344
|
}
|
|
273
345
|
|
|
@@ -4,7 +4,7 @@ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
|
|
|
4
4
|
export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
|
|
5
5
|
export const LEGACY_TELEMETRY_VERSION = 1 as const;
|
|
6
6
|
export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
|
|
7
|
-
export const TELEMETRY_PRODUCER_VERSION = "1.
|
|
7
|
+
export const TELEMETRY_PRODUCER_VERSION = "1.16.1" as const;
|
|
8
8
|
|
|
9
9
|
export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
|
|
10
10
|
export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});
|