@aeondave/pi-persona 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pipeline — run the roster agents in SEQUENCE, each building on the previous one's
|
|
3
|
+
* output (a chain / refinement / debate shape, vs `fanout`'s parallel and `magi`'s vote).
|
|
4
|
+
* The built-in behind L1 `mode: pipeline`. The pipeline's answer is the LAST step's output
|
|
5
|
+
* (the culmination); usage is summed across the chain and a failed step stops it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { emptyUsage } from "../../engine/stream.ts";
|
|
9
|
+
import { fenceUntrusted } from "../../core/fence.ts";
|
|
10
|
+
import { sumUsage } from "../reducers.ts";
|
|
11
|
+
import { rosterSpec } from "../roster.ts";
|
|
12
|
+
import type { Strategy } from "../sdk.ts";
|
|
13
|
+
import type { AgentResult } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
/** A chain the run cancelled — distinct from one a step failed, so a journal or supervisor
|
|
16
|
+
* records it as cancelled rather than as a broken step. The work already produced rides
|
|
17
|
+
* along: it cost real tokens and is still the best thing to show. */
|
|
18
|
+
function cancelled(results: AgentResult[], upstream: string): AgentResult {
|
|
19
|
+
return {
|
|
20
|
+
agent: "pipeline",
|
|
21
|
+
output: upstream || "(no output)",
|
|
22
|
+
structured: { steps: results.length, cancelled: true },
|
|
23
|
+
usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
|
|
24
|
+
ok: false,
|
|
25
|
+
error: "the run was aborted",
|
|
26
|
+
failureKind: "abort",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function failed(results: AgentResult[], upstream: string, step: AgentResult): AgentResult {
|
|
31
|
+
const cause = step.error ?? step.output ?? "failed";
|
|
32
|
+
const failedStep = `--- failed step (${step.agent}): ${cause} ---`;
|
|
33
|
+
const result: AgentResult = {
|
|
34
|
+
agent: "pipeline",
|
|
35
|
+
output: upstream ? `${upstream}\n\n${failedStep}` : (step.output || failedStep),
|
|
36
|
+
usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
|
|
37
|
+
ok: false,
|
|
38
|
+
};
|
|
39
|
+
if (step.error) result.error = step.error;
|
|
40
|
+
if (step.failureKind) result.failureKind = step.failureKind;
|
|
41
|
+
if (step.structured) result.structured = step.structured;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const pipeline: Strategy = {
|
|
46
|
+
name: "pipeline",
|
|
47
|
+
async run(input, sdk) {
|
|
48
|
+
const team = input.roster ? sdk.roster.team(input.roster) : [];
|
|
49
|
+
if (team.length === 0) throw new Error("pipeline: a non-empty roster is required");
|
|
50
|
+
sdk.log(`pipeline: ${team.length} agents in sequence`);
|
|
51
|
+
|
|
52
|
+
const results: AgentResult[] = [];
|
|
53
|
+
let upstream = "";
|
|
54
|
+
for (const member of team) {
|
|
55
|
+
// An abort settles a step as ok:false/'abort' instead of throwing, and one that lands
|
|
56
|
+
// between steps marks nothing at all — either way the chain must stop here rather than
|
|
57
|
+
// walk the rest of the roster.
|
|
58
|
+
if (sdk.signal?.aborted) return cancelled(results, upstream);
|
|
59
|
+
const task = upstream
|
|
60
|
+
? `${input.task}\n\n--- previous step's output (build on it) ---\n${fenceUntrusted(upstream)}`
|
|
61
|
+
: input.task;
|
|
62
|
+
const r = await sdk.agent({ ...rosterSpec(member), task });
|
|
63
|
+
results.push(r);
|
|
64
|
+
if (!r.ok) {
|
|
65
|
+
// A stop that lands WHILE this step runs settles it here, never at the loop guard
|
|
66
|
+
// above (the chain already broke): that guard reads `sdk.signal`, which is wired but
|
|
67
|
+
// only consulted BETWEEN steps. Report it as a cancelled chain that keeps the
|
|
68
|
+
// upstream work, not as a failed step whose empty output replaces it.
|
|
69
|
+
if (r.failureKind === "abort") return cancelled(results, upstream);
|
|
70
|
+
return failed(results, upstream, r);
|
|
71
|
+
}
|
|
72
|
+
if (r.output) upstream = r.output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const last = results[results.length - 1];
|
|
76
|
+
const result: AgentResult = {
|
|
77
|
+
agent: "pipeline",
|
|
78
|
+
output: last?.output ?? "(no output)",
|
|
79
|
+
usage: results.length > 0 ? sumUsage(results.map((r) => r.usage)) : emptyUsage(),
|
|
80
|
+
ok: results.length === team.length && results.every((r) => r.ok),
|
|
81
|
+
};
|
|
82
|
+
if (last?.structured) result.structured = last.structured;
|
|
83
|
+
// The chain breaks AT the failing step, so `last` carries the cause — without it the phase
|
|
84
|
+
// renders as failed with an empty output and nothing to act on.
|
|
85
|
+
if (!result.ok && last && !last.ok) {
|
|
86
|
+
if (last.error) result.error = last.error;
|
|
87
|
+
if (last.failureKind) result.failureKind = last.failureKind;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* synthesize — fan the roster out on the task in parallel, then ONE synthesiser agent
|
|
3
|
+
* merges the labeled findings into a single coherent answer. This is the "reduce" that
|
|
4
|
+
* `fanout` lacks (its aggregate is only concatenated sections): use it whenever the
|
|
5
|
+
* ensemble's value is a unified deliverable — research sweeps, multi-angle reviews,
|
|
6
|
+
* gather-then-write — rather than a vote (`magi`) or a pick (`judge`).
|
|
7
|
+
*
|
|
8
|
+
* roster = the gatherers · params = { synthesizer?: "<agent>" (default: the first roster agent),
|
|
9
|
+
* peers?: boolean (gatherers share contradictions/corroborations live — default off) }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { rosterSpec } from "../roster.ts";
|
|
13
|
+
import { fenceUntrusted } from "../../core/fence.ts";
|
|
14
|
+
import { sumUsage, summarizeFailedResults } from "../reducers.ts";
|
|
15
|
+
import type { Strategy } from "../sdk.ts";
|
|
16
|
+
import type { AgentResult } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
// Cooperative cross-talk (params.peers): gatherers surface contradictions early instead of
|
|
19
|
+
// leaving them all to the synthesizer. Task-text injection keeps UI tree keys stable.
|
|
20
|
+
const CROSS_TALK = [
|
|
21
|
+
"You have sibling gatherers working OTHER angles of this same task. If you find something that",
|
|
22
|
+
"contradicts or strongly corroborates what another angle would see, share it once via",
|
|
23
|
+
"`contact_peer` (action `list`, then `send`) — short and factual. Incorporate any",
|
|
24
|
+
'"[message from peer …]" notes you receive. No chatter: only load-bearing findings.',
|
|
25
|
+
].join(" ");
|
|
26
|
+
|
|
27
|
+
export const synthesize: Strategy = {
|
|
28
|
+
name: "synthesize",
|
|
29
|
+
params: {
|
|
30
|
+
synthesizer: { type: "string", doc: "default: the first roster agent" },
|
|
31
|
+
peers: { type: "boolean", default: false, doc: "gatherers share contradictions/corroborations live" },
|
|
32
|
+
},
|
|
33
|
+
async run(input, sdk) {
|
|
34
|
+
const team = input.roster ? sdk.roster.team(input.roster) : [];
|
|
35
|
+
if (team.length === 0) throw new Error("synthesize: a non-empty roster (the gatherers) is required");
|
|
36
|
+
const synthesizer =
|
|
37
|
+
typeof input.params.synthesizer === "string" && input.params.synthesizer.trim()
|
|
38
|
+
? input.params.synthesizer.trim()
|
|
39
|
+
: rosterSpec(team[0]!).agent;
|
|
40
|
+
const peers = input.params.peers === true;
|
|
41
|
+
sdk.log(`synthesize: ${team.length} gatherers → ${synthesizer}${peers ? " (cross-talk on)" : ""}`);
|
|
42
|
+
if (peers && team.length > sdk.limits.maxConcurrency) {
|
|
43
|
+
sdk.log(
|
|
44
|
+
`synthesize: ${team.length} gatherers exceeds maxConcurrency (${sdk.limits.maxConcurrency}) — the live exchange will be batched (gatherers beyond the concurrency window join late)`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const results = await sdk.parallel(
|
|
49
|
+
team.map((m) => () =>
|
|
50
|
+
sdk.agent({
|
|
51
|
+
...rosterSpec(m),
|
|
52
|
+
task: peers ? `${input.task}\n\n--- gatherer cross-talk ---\n${CROSS_TALK}` : input.task,
|
|
53
|
+
...(peers ? { peers: true } : {}),
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
const usable = results.filter((r) => r.ok && r.output.trim());
|
|
58
|
+
if (usable.length === 0) {
|
|
59
|
+
const cause = summarizeFailedResults(results, "no gatherer produced output");
|
|
60
|
+
const reasons = results.map((r) => `[${r.agent}] ${r.error ?? "(no output)"}`).join("; ");
|
|
61
|
+
return {
|
|
62
|
+
agent: "synthesize",
|
|
63
|
+
output: `(no gatherer produced output: ${reasons})`,
|
|
64
|
+
usage: sumUsage(results.map((r) => r.usage)),
|
|
65
|
+
ok: false,
|
|
66
|
+
error: cause.error,
|
|
67
|
+
failureKind: cause.failureKind,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sections = usable.map((r) => `--- [${r.agent}] ---\n${fenceUntrusted(r.output.trim())}`).join("\n\n");
|
|
72
|
+
const final = await sdk.agent({
|
|
73
|
+
agent: synthesizer,
|
|
74
|
+
task:
|
|
75
|
+
`Synthesise the findings below into ONE coherent, de-duplicated answer to the task. ` +
|
|
76
|
+
`Resolve contradictions explicitly (say which finding you trusted and why) and keep every load-bearing detail.\n\n` +
|
|
77
|
+
`Task: ${input.task}\n\nFindings:\n${sections}`,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A one-line headline for the collapsed council card (the tool reads `headline`):
|
|
81
|
+
// the synthesiser's `result` field if it emitted one, else the first non-empty line.
|
|
82
|
+
const firstLine = final.output.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
83
|
+
const structuredResult = final.structured?.result;
|
|
84
|
+
const headline = (typeof structuredResult === "string" && structuredResult.trim() ? structuredResult : firstLine).slice(0, 120);
|
|
85
|
+
|
|
86
|
+
const result: AgentResult = {
|
|
87
|
+
agent: "synthesize",
|
|
88
|
+
output: final.output,
|
|
89
|
+
structured: { ...(final.structured ?? {}), gatherers: usable.length, headline },
|
|
90
|
+
usage: sumUsage([...results, final].map((r) => r.usage)),
|
|
91
|
+
ok: final.ok,
|
|
92
|
+
};
|
|
93
|
+
if (!final.ok) {
|
|
94
|
+
result.error = final.error ?? "the synthesizer failed";
|
|
95
|
+
result.failureKind = final.failureKind ?? "agent";
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in strategy registry — TS modules conforming to the `Strategy` interface.
|
|
3
|
+
* A future loader for user-authored `strategies/*.js` (same SDK, trusted/project-
|
|
4
|
+
* gated code) would register through the same map.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Strategy, StrategyParam } from "./sdk.ts";
|
|
8
|
+
import { compete } from "./strategies/compete.ts";
|
|
9
|
+
import { councilRounds } from "./strategies/council-rounds.ts";
|
|
10
|
+
import { criticLoop } from "./strategies/critic-loop.ts";
|
|
11
|
+
import { debate } from "./strategies/debate.ts";
|
|
12
|
+
import { fanout } from "./strategies/fanout.ts";
|
|
13
|
+
import { judge } from "./strategies/judge.ts";
|
|
14
|
+
import { magi } from "./strategies/magi.ts";
|
|
15
|
+
import { map } from "./strategies/map.ts";
|
|
16
|
+
import { pair } from "./strategies/pair.ts";
|
|
17
|
+
import { pipeline } from "./strategies/pipeline.ts";
|
|
18
|
+
import { synthesize } from "./strategies/synthesize.ts";
|
|
19
|
+
|
|
20
|
+
const BUILTINS: Record<string, Strategy> = {
|
|
21
|
+
fanout,
|
|
22
|
+
pipeline,
|
|
23
|
+
map,
|
|
24
|
+
"critic-loop": criticLoop,
|
|
25
|
+
"council-rounds": councilRounds,
|
|
26
|
+
debate,
|
|
27
|
+
magi,
|
|
28
|
+
judge,
|
|
29
|
+
pair,
|
|
30
|
+
synthesize,
|
|
31
|
+
compete,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function getStrategy(name: string): Strategy | undefined {
|
|
35
|
+
return BUILTINS[name];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The registered built-in strategy names, sorted — for error messages and discovery. */
|
|
39
|
+
export function strategyNames(): string[] {
|
|
40
|
+
return Object.keys(BUILTINS).sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A strategy's declared param schema, for discovery (`/doctor`) and the council tool's
|
|
44
|
+
* lenient unknown-key warning. Known param-less strategies return an empty schema;
|
|
45
|
+
* `undefined` is reserved for an unknown strategy. */
|
|
46
|
+
export function knownParams(name: string): Record<string, StrategyParam> | undefined {
|
|
47
|
+
const strategy = BUILTINS[name];
|
|
48
|
+
return strategy ? (strategy.params ?? {}) : undefined;
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration-layer types shared by the SDK, strategies, and reducers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ChildUsage } from "../engine/stream.ts";
|
|
6
|
+
|
|
7
|
+
export type { ChildUsage };
|
|
8
|
+
|
|
9
|
+
/** Machine-readable cause of an `ok:false` run — so callers can react by CAUSE, not by
|
|
10
|
+
* string-matching the error. Only `"provider"` (the model's provider rejected/broke:
|
|
11
|
+
* auth, outage, 5xx, model-not-supported) is retryable by switching provider; the rest
|
|
12
|
+
* are terminal for that model. `"verification"` is the one value that never appears on an
|
|
13
|
+
* `AgentResult` itself — it appears only on `map`'s per-item ledger entry (`ItemLedgerEntry`,
|
|
14
|
+
* `orchestration/reducers.ts`), where it means the worker LEG succeeded but a separate
|
|
15
|
+
* `params.verify` reviewer rejected that item's work after the fact. */
|
|
16
|
+
export type FailureKind = "provider" | "abort" | "timeout" | "contract" | "unknown-agent" | "unknown-model" | "agent" | "verification";
|
|
17
|
+
|
|
18
|
+
/** The result of running one agent through the engine, as strategies see it. */
|
|
19
|
+
export interface AgentResult {
|
|
20
|
+
agent: string;
|
|
21
|
+
output: string;
|
|
22
|
+
/** Validated structured fields (when the agent ran against a contract). */
|
|
23
|
+
structured?: Record<string, unknown>;
|
|
24
|
+
usage: ChildUsage;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
/** The canonical `provider/id` the run actually used (drives provider fallback + UI). */
|
|
28
|
+
modelUsed?: string;
|
|
29
|
+
/** Why it failed (set only when `ok` is false) — drives the model-fallback decision. */
|
|
30
|
+
failureKind?: FailureKind;
|
|
31
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voting reducers (v0.2). Fold candidate results into a decision with an honest
|
|
3
|
+
* status union — never just a `winner`. Built-in bias guards: invalid outputs
|
|
4
|
+
* are quarantined, dissent is always preserved, and ties/no-consensus are
|
|
5
|
+
* surfaced (with an optional keep-best-by-confidence fallback). Anonymisation /
|
|
6
|
+
* order-shuffling apply to judge-based aggregation (added with the judge reducer).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentResult } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export type ReducerStatus = "winner" | "tie" | "no_consensus" | "invalid_outputs";
|
|
12
|
+
|
|
13
|
+
export interface ReducerResult {
|
|
14
|
+
status: ReducerStatus;
|
|
15
|
+
winner?: AgentResult;
|
|
16
|
+
dissent?: AgentResult[];
|
|
17
|
+
invalid?: AgentResult[];
|
|
18
|
+
tally: Record<string, number>;
|
|
19
|
+
usedFallback: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VoteOpts {
|
|
23
|
+
aggregate: "majority" | "unanimity";
|
|
24
|
+
keepBestFallback?: boolean;
|
|
25
|
+
/** Best-of-X: a plurality winner must reach at least this many votes, else no_consensus. */
|
|
26
|
+
threshold?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalise a vote so case/separator variants tally together
|
|
30
|
+
* (`json-first`, `JSON_First`, `json first` → `json-first`). */
|
|
31
|
+
function normalizeVote(value: string): string {
|
|
32
|
+
return value
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[\s_-]+/g, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The canonical (normalised) vote key for a candidate, or undefined if it cannot vote. */
|
|
40
|
+
function voteKey(r: AgentResult): string | undefined {
|
|
41
|
+
if (!r.ok) return undefined;
|
|
42
|
+
const s = r.structured;
|
|
43
|
+
if (s && typeof s.vote === "string" && s.vote.trim()) return normalizeVote(s.vote);
|
|
44
|
+
if (s && typeof s.result === "string" && s.result.trim()) return normalizeVote(s.result);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function confidence(r: AgentResult): number {
|
|
49
|
+
const c = r.structured?.confidence;
|
|
50
|
+
return typeof c === "number" ? c : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function voteReduce(candidates: AgentResult[], opts: VoteOpts): ReducerResult {
|
|
54
|
+
const valid: Array<{ result: AgentResult; key: string }> = [];
|
|
55
|
+
const invalid: AgentResult[] = [];
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const key = voteKey(candidate);
|
|
58
|
+
if (key === undefined) invalid.push(candidate);
|
|
59
|
+
else valid.push({ result: candidate, key });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tally: Record<string, number> = {};
|
|
63
|
+
for (const v of valid) tally[v.key] = (tally[v.key] ?? 0) + 1;
|
|
64
|
+
|
|
65
|
+
if (valid.length === 0) {
|
|
66
|
+
// Nobody emitted a parseable vote. With keepBestFallback, don't strand the caller with an
|
|
67
|
+
// empty ruling (small models often drop the vote JSON) — surface the strongest single PROSE
|
|
68
|
+
// answer among the candidates that actually ANSWERED. That means ok candidates AND
|
|
69
|
+
// contract-only failures: an engine marks a member that answered in prose instead of the
|
|
70
|
+
// vote JSON as `ok:false, failureKind:"contract"`, which is precisely the case this rescue
|
|
71
|
+
// exists for (requiring `ok` here would make it unreachable — live-drive verified: a debate
|
|
72
|
+
// over generic agents returned an empty ruling). Hard failures (timeout/abort/provider/
|
|
73
|
+
// agent) stay excluded. No usable prose ⇒ genuinely nothing to show.
|
|
74
|
+
const res: ReducerResult = { status: "invalid_outputs", invalid, tally, usedFallback: false };
|
|
75
|
+
if (opts.keepBestFallback) {
|
|
76
|
+
const prose = candidates.filter((c) => c.output.trim() && (c.ok || c.failureKind === "contract"));
|
|
77
|
+
const winner = [...prose].sort((a, b) => confidence(b) - confidence(a))[0];
|
|
78
|
+
if (winner) {
|
|
79
|
+
res.winner = winner;
|
|
80
|
+
res.dissent = prose.filter((c) => c !== winner);
|
|
81
|
+
res.usedFallback = true;
|
|
82
|
+
// The rescued prose (winner + dissent) is SURFACED, not excluded — keep only the
|
|
83
|
+
// candidates that produced nothing usable in `invalid`, so an "N invalid excluded"
|
|
84
|
+
// footer counts what was actually dropped, not the ruling it sits above.
|
|
85
|
+
res.invalid = invalid.filter((c) => c !== winner && !res.dissent!.includes(c));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return res;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const bestByConfidence = (): AgentResult =>
|
|
92
|
+
[...valid].sort((a, b) => confidence(b.result) - confidence(a.result))[0]!.result;
|
|
93
|
+
|
|
94
|
+
const withFallback = (status: ReducerStatus): ReducerResult => {
|
|
95
|
+
const res: ReducerResult = { status, invalid, tally, usedFallback: false };
|
|
96
|
+
if (opts.keepBestFallback) {
|
|
97
|
+
const winner = bestByConfidence();
|
|
98
|
+
res.winner = winner;
|
|
99
|
+
// Preserve the minority report even when we fall back — this is exactly the
|
|
100
|
+
// case (tie / no_consensus) where the dissent matters most.
|
|
101
|
+
res.dissent = valid.filter((v) => v.result !== winner).map((v) => v.result);
|
|
102
|
+
res.usedFallback = true;
|
|
103
|
+
}
|
|
104
|
+
return res;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (opts.aggregate === "unanimity") {
|
|
108
|
+
if (Object.keys(tally).length === 1) {
|
|
109
|
+
return { status: "winner", winner: valid[0]!.result, dissent: [], invalid, tally, usedFallback: false };
|
|
110
|
+
}
|
|
111
|
+
return withFallback("no_consensus");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// majority
|
|
115
|
+
const max = Math.max(...Object.values(tally));
|
|
116
|
+
const top = Object.keys(tally).filter((k) => tally[k] === max);
|
|
117
|
+
if (top.length === 1) {
|
|
118
|
+
// Best-of-X: a clear plurality still needs `threshold` votes to win outright.
|
|
119
|
+
if (opts.threshold !== undefined && max < opts.threshold) return withFallback("no_consensus");
|
|
120
|
+
const winnerKey = top[0];
|
|
121
|
+
const winner = valid.find((v) => v.key === winnerKey)!.result;
|
|
122
|
+
const dissent = valid.filter((v) => v.key !== winnerKey).map((v) => v.result);
|
|
123
|
+
return { status: "winner", winner, dissent, invalid, tally, usedFallback: false };
|
|
124
|
+
}
|
|
125
|
+
return withFallback("tie");
|
|
126
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-persona configuration, indexed by persona name and persisted as one JSON
|
|
3
|
+
* file in the global persona folder. Intentionally open-ended: a persona that
|
|
4
|
+
* needs settings gets an entry, others don't. Two personas that share a strategy
|
|
5
|
+
* keep independent configs. Today it carries per-agent model assignments (so an
|
|
6
|
+
* ensemble like magi can run its cores on *different* models); more keys later.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface PersonaConfig {
|
|
13
|
+
/** Roster agent name → provider-qualified model id (e.g. "claude-pro-max-native/claude-opus-4-8"). */
|
|
14
|
+
models?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PersonaConfigStore = Record<string, PersonaConfig>;
|
|
18
|
+
|
|
19
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the store. NOTE: this reader WRITES on one path — an unusable file is copied to
|
|
25
|
+
* `${file}.bak` before the next save replaces it (once; an existing backup is never touched).
|
|
26
|
+
*/
|
|
27
|
+
export function readPersonaConfigs(file: string): PersonaConfigStore {
|
|
28
|
+
let text: string;
|
|
29
|
+
try {
|
|
30
|
+
text = readFileSync(file, "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
return {}; // no config yet (the common case) or unreadable → start empty
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const parsed: unknown = JSON.parse(text);
|
|
36
|
+
// Valid JSON that isn't a config object is as unusable as unparseable text — and the next
|
|
37
|
+
// save replaces it just the same, so it gets the same backup treatment.
|
|
38
|
+
if (!isPlainObject(parsed)) throw new Error("not a config object");
|
|
39
|
+
const store: PersonaConfigStore = {};
|
|
40
|
+
for (const [name, cfg] of Object.entries(parsed)) {
|
|
41
|
+
if (!isPlainObject(cfg)) continue;
|
|
42
|
+
const entry: PersonaConfig = {};
|
|
43
|
+
if (isPlainObject(cfg.models)) {
|
|
44
|
+
const models: Record<string, string> = {};
|
|
45
|
+
for (const [agent, model] of Object.entries(cfg.models)) {
|
|
46
|
+
if (typeof model === "string") models[agent] = model;
|
|
47
|
+
}
|
|
48
|
+
entry.models = models;
|
|
49
|
+
}
|
|
50
|
+
store[name] = entry;
|
|
51
|
+
}
|
|
52
|
+
return store;
|
|
53
|
+
} catch {
|
|
54
|
+
// An unusable file is about to be overwritten by the next save; keep it beside the
|
|
55
|
+
// config so the assignments stay recoverable instead of vanishing silently. `wx` makes
|
|
56
|
+
// this the FIRST copy only: the underlying fault (a sync client, a failing disk) tends to
|
|
57
|
+
// recur, and a second bad parse would otherwise overwrite the one copy that still had the
|
|
58
|
+
// user's assignments in it.
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(`${file}.bak`, text, { encoding: "utf8", flag: "wx" });
|
|
61
|
+
} catch {
|
|
62
|
+
/* best effort — an existing backup, or an unwritable dir, must not stop the session */
|
|
63
|
+
}
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Injection seams for the rename retry below (tests drive it on any OS). */
|
|
69
|
+
export interface ConfigWriteIO {
|
|
70
|
+
rename?: (from: string, to: string) => void;
|
|
71
|
+
sleep?: (ms: number) => void;
|
|
72
|
+
/** Test seam for the cross-process lock around the read/merge/write transaction. */
|
|
73
|
+
acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A handle held by a virus scanner, a sync client or another pi instance mid-read is transient,
|
|
77
|
+
// so a few short waits usually win the race — 25+50+75 ms of backoff at worst, on a save.
|
|
78
|
+
const RENAME_ATTEMPTS = 4;
|
|
79
|
+
const RENAME_BACKOFF_MS = 25;
|
|
80
|
+
const LOCK_ATTEMPTS = 80;
|
|
81
|
+
const LOCK_BACKOFF_MS = 25;
|
|
82
|
+
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
function sleepSync(ms: number): void {
|
|
85
|
+
// The whole store API is synchronous (this runs on a model assignment, not on a hot path),
|
|
86
|
+
// so the backoff has to block; Atomics.wait is the standard synchronous sleep.
|
|
87
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Acquire a small sidecar lock so two independent Pi processes cannot both read the same old
|
|
91
|
+
* config and race their read-modify-write transactions. `wx` is the portable atomic primitive on
|
|
92
|
+
* POSIX and Windows. A very old lock is treated as abandoned after a crashed process; active
|
|
93
|
+
* writers renew no state because the critical section is synchronous and short. */
|
|
94
|
+
function acquireFileLock(file: string, sleep: (ms: number) => void): () => void {
|
|
95
|
+
const lock = `${file}.lock`;
|
|
96
|
+
for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
|
|
97
|
+
try {
|
|
98
|
+
const fd = openSync(lock, "wx");
|
|
99
|
+
try {
|
|
100
|
+
writeSync(fd, `${process.pid}\n`, null, "utf8");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
try {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
} finally {
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(lock);
|
|
107
|
+
} catch {
|
|
108
|
+
/* best effort; retain the original write error */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
return () => {
|
|
114
|
+
try {
|
|
115
|
+
closeSync(fd);
|
|
116
|
+
} finally {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(lock);
|
|
119
|
+
} catch {
|
|
120
|
+
/* another cleanup path or an external remover already won */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
126
|
+
if (code !== "EEXIST") throw error;
|
|
127
|
+
try {
|
|
128
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) unlinkSync(lock);
|
|
129
|
+
} catch {
|
|
130
|
+
/* It disappeared between stat/unlink; retry normally. */
|
|
131
|
+
}
|
|
132
|
+
if (attempt < LOCK_ATTEMPTS) {
|
|
133
|
+
sleep(LOCK_BACKOFF_MS);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`timed out acquiring persona config lock: ${lock}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`timed out acquiring persona config lock: ${lock}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* MERGE `store` into the file (read-modify-write), then save it.
|
|
144
|
+
*
|
|
145
|
+
* A removal is NOT expressible: an assignment missing from `store` is preserved, not deleted.
|
|
146
|
+
* The merge exists so a concurrent pi instance's save survives ours, and it cannot tell "the
|
|
147
|
+
* user cleared this" from "this store was read before that was written" — a clearing gesture
|
|
148
|
+
* would need its own delete-aware call.
|
|
149
|
+
*/
|
|
150
|
+
export function writePersonaConfigs(file: string, store: PersonaConfigStore, io: ConfigWriteIO = {}): void {
|
|
151
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
152
|
+
const sleep = io.sleep ?? sleepSync;
|
|
153
|
+
const release = (io.acquireLock ?? acquireFileLock)(file, sleep);
|
|
154
|
+
try {
|
|
155
|
+
// The caller holds a store read at session start; another pi instance in the same workspace
|
|
156
|
+
// may have saved since. Re-read and merge so its work survives our whole-store write — down
|
|
157
|
+
// to the agent, since two instances of the *same* persona assign different roster members.
|
|
158
|
+
const merged: PersonaConfigStore = { ...readPersonaConfigs(file) };
|
|
159
|
+
for (const [persona, cfg] of Object.entries(store)) {
|
|
160
|
+
const prev = merged[persona];
|
|
161
|
+
merged[persona] = prev ? { ...prev, ...cfg, models: { ...prev.models, ...cfg.models } } : cfg;
|
|
162
|
+
}
|
|
163
|
+
const payload = `${JSON.stringify(merged, null, 2)}\n`;
|
|
164
|
+
// Temp file + same-volume rename, so a crash mid-write can never leave a truncated
|
|
165
|
+
// config.json behind. That is atomic on POSIX; Windows is NOT the same — rename fails with
|
|
166
|
+
// EPERM/EBUSY while ANY other handle holds the target open (Defender, a sync client, a second
|
|
167
|
+
// pi instance mid-read), and the persona folder is an ordinary user directory where that is
|
|
168
|
+
// routine. So a contended rename is retried, and then written in place: a non-atomic save
|
|
169
|
+
// that lands beats an atomic one that loses the user's assignments every session.
|
|
170
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
171
|
+
const rename = io.rename ?? renameSync;
|
|
172
|
+
writeFileSync(tmp, payload, "utf8");
|
|
173
|
+
for (let attempt = 1; ; attempt++) {
|
|
174
|
+
try {
|
|
175
|
+
rename(tmp, file);
|
|
176
|
+
return;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
179
|
+
if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
|
|
180
|
+
sleep(RENAME_BACKOFF_MS * attempt);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
// The scratch file must not be left behind for every pid that ever tried, whether
|
|
185
|
+
// the in-place write succeeds or throws (the caller still sees a real failure).
|
|
186
|
+
writeFileSync(file, payload, "utf8");
|
|
187
|
+
} finally {
|
|
188
|
+
try {
|
|
189
|
+
rmSync(tmp, { force: true });
|
|
190
|
+
} catch {
|
|
191
|
+
/* best effort */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
release();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The model assignments for a persona (empty object when none configured). */
|
|
203
|
+
export function personaModels(store: PersonaConfigStore, persona: string): Record<string, string> {
|
|
204
|
+
return store[persona]?.models ?? {};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Merge model assignments into a persona's config, returning a new store. */
|
|
208
|
+
export function withPersonaModels(
|
|
209
|
+
store: PersonaConfigStore,
|
|
210
|
+
persona: string,
|
|
211
|
+
models: Record<string, string>,
|
|
212
|
+
): PersonaConfigStore {
|
|
213
|
+
const prev = store[persona] ?? {};
|
|
214
|
+
return { ...store, [persona]: { ...prev, models: { ...prev.models, ...models } } };
|
|
215
|
+
}
|