@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,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow runner (v0.5) — execute a {@link FlowSpec} DAG: run every phase's strategy in
|
|
3
|
+
* dependency order, fanning out phases whose `needs` are all satisfied in parallel,
|
|
4
|
+
* threading each upstream phase's output into its dependents' task. Journals every
|
|
5
|
+
* completed phase (one JSONL line) so a later run can RESUME by skipping done phases. A
|
|
6
|
+
* failed phase blocks its dependents (they can't run without their input). The strategy
|
|
7
|
+
* runner is injected (`deps.runPhase`), so the DAG logic is engine-agnostic and tested
|
|
8
|
+
* with a stub.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
12
|
+
import { emptyUsage } from "../engine/stream.ts";
|
|
13
|
+
import type { FlowPhase, FlowSpec } from "./flow.ts";
|
|
14
|
+
import type { AgentResult, FailureKind } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export type PhaseStatus = "running" | "done" | "failed";
|
|
17
|
+
|
|
18
|
+
/** One phase ready to run: its spec, the built task, and its upstream phases' results. */
|
|
19
|
+
export interface FlowPhaseRun {
|
|
20
|
+
phase: FlowPhase;
|
|
21
|
+
task: string;
|
|
22
|
+
upstream: Record<string, AgentResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RunPhaseFn = (run: FlowPhaseRun) => Promise<AgentResult>;
|
|
26
|
+
|
|
27
|
+
/** One journal line — appended as each phase completes (for resume). */
|
|
28
|
+
export interface FlowJournalEntry {
|
|
29
|
+
phase: string;
|
|
30
|
+
hash: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
output: string;
|
|
33
|
+
/** A gated phase whose checkpoint was approved — so a resume doesn't re-prompt. */
|
|
34
|
+
gateApproved?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A resume map value: a phase result plus whether its checkpoint gate was already approved. */
|
|
38
|
+
export type ResumedResult = AgentResult & { gateApproved?: boolean };
|
|
39
|
+
|
|
40
|
+
export interface FlowRunDeps {
|
|
41
|
+
/** Run one phase's strategy → its result (wraps `runPersonaStrategy` in prod). */
|
|
42
|
+
runPhase: RunPhaseFn;
|
|
43
|
+
/** The pinned `flow@hash` for this run (I3) — recorded in every journal line. */
|
|
44
|
+
hash: string;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
/** Grace an aborted wave gets to unwind cooperatively before its still-running phases are
|
|
47
|
+
* force-settled (defaults to {@link ABORT_GRACE_MS}); shrinkable in tests. */
|
|
48
|
+
abortGraceMs?: number;
|
|
49
|
+
/** Append one entry as each phase completes. */
|
|
50
|
+
journal?: (entry: FlowJournalEntry) => void;
|
|
51
|
+
/** Phases already completed (from a prior run's journal) — skipped, output reused. */
|
|
52
|
+
resume?: Record<string, ResumedResult>;
|
|
53
|
+
/** Live UI: phase lifecycle. */
|
|
54
|
+
onPhase?: (id: string, status: PhaseStatus, result?: AgentResult) => void;
|
|
55
|
+
/** A gated phase's checkpoint: after it completes, approve before its dependents run.
|
|
56
|
+
* Absent ⇒ gates auto-approve (informational only — headless runs aren't blocked). */
|
|
57
|
+
approveGate?: (phase: FlowPhase, result: AgentResult) => Promise<boolean>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface FlowOutcome {
|
|
61
|
+
ok: boolean;
|
|
62
|
+
results: Record<string, AgentResult>;
|
|
63
|
+
/** The terminal (sink) phases' combined output — the flow's answer. */
|
|
64
|
+
output: string;
|
|
65
|
+
/** The first phase that failed, when a settled result identifies one. */
|
|
66
|
+
failedPhase?: string;
|
|
67
|
+
/** The actionable cause of the flow's first failure, or the cancellation cause. */
|
|
68
|
+
error?: string;
|
|
69
|
+
/** The machine-readable cause of the flow failure. */
|
|
70
|
+
failureKind?: FailureKind;
|
|
71
|
+
/** The run signal fired before every phase settled ok. A cancelled flow is NOT the same as one
|
|
72
|
+
* that merely finished without an answer, and callers report it differently. */
|
|
73
|
+
cancelled?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How long an aborted wave is given to unwind COOPERATIVELY before its still-running phases are
|
|
77
|
+
* force-settled as cancelled. A strategy that consults the run signal returns its own (more
|
|
78
|
+
* informative) result — the last good draft, the upstream step's output, the usage already
|
|
79
|
+
* billed — inside this window; a strategy that ignores the signal must not be able to hold the
|
|
80
|
+
* whole run open. Sized for the cooperative path to actually WIN it: a strategy notices at a
|
|
81
|
+
* round boundary, and only once its in-flight legs have settled through the engine's abort path
|
|
82
|
+
* (an in-process session stop, or a child process tree kill), which is seconds rather than
|
|
83
|
+
* milliseconds. The full wait is paid only by a phase that never unwinds at all. */
|
|
84
|
+
const ABORT_GRACE_MS = 5_000;
|
|
85
|
+
|
|
86
|
+
const CANCELLED_ERROR = "cancelled: the flow run was aborted";
|
|
87
|
+
|
|
88
|
+
/** Resolves when `signal` aborts — never, when there is none. `dispose` drops the listener, so a
|
|
89
|
+
* many-wave run doesn't accumulate one per wave on a long-lived signal. */
|
|
90
|
+
function whenAborted(signal: AbortSignal | undefined): { promise: Promise<void>; dispose: () => void } {
|
|
91
|
+
if (!signal) return { promise: new Promise<void>(() => { /* never */ }), dispose: () => { /* nothing to drop */ } };
|
|
92
|
+
let onAbort = (): void => { /* replaced below */ };
|
|
93
|
+
const promise = new Promise<void>((resolve) => {
|
|
94
|
+
onAbort = (): void => resolve();
|
|
95
|
+
if (signal.aborted) resolve();
|
|
96
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
});
|
|
98
|
+
return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Await `p`, but give up after `ms`. The timer is unref'd: a pending grace must never be the
|
|
102
|
+
* reason a process stays alive. */
|
|
103
|
+
function within(p: Promise<unknown>, ms: number): Promise<void> {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const timer = setTimeout(resolve, ms);
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
const settle = (): void => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
};
|
|
111
|
+
void p.then(settle, settle);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a phase's task: the base task plus each upstream phase's output as context. */
|
|
116
|
+
function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upstream: Record<string, AgentResult>): string {
|
|
117
|
+
const parts = [`Flow "${spec.name}" — phase "${phase.id}" (strategy: ${phase.strategy}).`, baseTask];
|
|
118
|
+
const ups = Object.entries(upstream);
|
|
119
|
+
if (ups.length > 0) {
|
|
120
|
+
parts.push("\nUpstream results to build on:");
|
|
121
|
+
for (const [id, r] of ups) parts.push(`\n--- ${id} ---\n${fenceUntrusted(r.output)}`);
|
|
122
|
+
}
|
|
123
|
+
return parts.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDeps): Promise<FlowOutcome> {
|
|
127
|
+
const byId = new Map(spec.phases.map((p) => [p.id, p]));
|
|
128
|
+
// A gated phase only satisfies its dependents once its checkpoint is "passed" (or it blocks
|
|
129
|
+
// them once "rejected"). Resume pre-seeds gates the journal already recorded as approved.
|
|
130
|
+
const gateState = new Map<string, "passed" | "rejected">();
|
|
131
|
+
const done = new Map<string, AgentResult>();
|
|
132
|
+
for (const [id, r] of Object.entries(deps.resume ?? {})) {
|
|
133
|
+
const gated = byId.get(id)?.gate === true;
|
|
134
|
+
// A gated phase resumed as ok but WITHOUT a recorded approval (the process died between
|
|
135
|
+
// journaling its completion and its gate decision) must RE-RUN, not be treated as done —
|
|
136
|
+
// otherwise it sits in `done` with an unresolved gate and its dependents can never run.
|
|
137
|
+
if (gated && r.ok && !(r as ResumedResult).gateApproved) continue;
|
|
138
|
+
done.set(id, r);
|
|
139
|
+
if (gated && (r as ResumedResult).gateApproved) gateState.set(id, "passed");
|
|
140
|
+
}
|
|
141
|
+
// A FAILED gated phase has no checkpoint to resolve — its gate must not keep dependents
|
|
142
|
+
// from being considered (needFailed then records them "blocked", exactly like a failed
|
|
143
|
+
// non-gated need; otherwise they'd never become ready and silently vanish, stuck ⏳).
|
|
144
|
+
const gateResolved = (id: string): boolean => !byId.get(id)?.gate || gateState.has(id) || done.get(id)?.ok === false;
|
|
145
|
+
const needFailed = (n: string): boolean => (done.has(n) && !done.get(n)?.ok) || gateState.get(n) === "rejected";
|
|
146
|
+
let remaining = spec.phases.filter((p) => !done.has(p.id));
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
// Keep blocked dependents distinct from phases that actually ran and failed. The flow's
|
|
149
|
+
// declaration order is not necessarily topological, so a dependent can appear before the
|
|
150
|
+
// root failure in `spec.phases`; it must never hide that root cause in the final outcome.
|
|
151
|
+
const blockedPhaseIds = new Set<string>();
|
|
152
|
+
|
|
153
|
+
while (remaining.length > 0) {
|
|
154
|
+
if (deps.signal?.aborted) {
|
|
155
|
+
cancelled = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
// A phase can be considered once every need is done AND any gated need is resolved.
|
|
159
|
+
const ready = remaining.filter((p) => (p.needs ?? []).every((n) => done.has(n) && gateResolved(n)));
|
|
160
|
+
if (ready.length === 0) break; // acyclic + progress guarantees this can't happen
|
|
161
|
+
|
|
162
|
+
// A phase whose any need FAILED or whose gate was REJECTED is blocked — record, don't run.
|
|
163
|
+
const blocked = ready.filter((p) => (p.needs ?? []).some((n) => needFailed(n)));
|
|
164
|
+
for (const p of blocked) {
|
|
165
|
+
blockedPhaseIds.add(p.id);
|
|
166
|
+
const gateReject = (p.needs ?? []).some((n) => gateState.get(n) === "rejected");
|
|
167
|
+
const error = gateReject ? "blocked: a checkpoint gate was not approved" : "blocked: an upstream phase failed";
|
|
168
|
+
const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
|
|
169
|
+
done.set(p.id, r);
|
|
170
|
+
deps.onPhase?.(p.id, "failed", r);
|
|
171
|
+
deps.journal?.({ phase: p.id, hash: deps.hash, ok: false, output: "" });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const runnable = ready.filter((p) => !blocked.includes(p));
|
|
175
|
+
// Phases that produced their own result. Anything still missing when an aborted wave's
|
|
176
|
+
// grace expires is force-settled below, so a run-level abort can cut a wave already in
|
|
177
|
+
// flight instead of waiting on strategies that never consult the signal.
|
|
178
|
+
const settled = new Map<string, AgentResult>();
|
|
179
|
+
let forced = false;
|
|
180
|
+
const wave = Promise.all(
|
|
181
|
+
runnable.map(async (p): Promise<void> => {
|
|
182
|
+
deps.onPhase?.(p.id, "running");
|
|
183
|
+
const upstream: Record<string, AgentResult> = {};
|
|
184
|
+
for (const n of p.needs ?? []) {
|
|
185
|
+
const u = done.get(n);
|
|
186
|
+
if (u) upstream[n] = u;
|
|
187
|
+
}
|
|
188
|
+
const task = buildPhaseTask(baseTask, spec, p, upstream);
|
|
189
|
+
// A throwing phase (e.g. a misconfigured strategy) is a phase FAILURE, not a
|
|
190
|
+
// crashed DAG — record it failed so its dependents block and the flow finishes.
|
|
191
|
+
let r: AgentResult;
|
|
192
|
+
try {
|
|
193
|
+
r = await deps.runPhase({ phase: p, task, upstream });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
196
|
+
r = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error };
|
|
197
|
+
}
|
|
198
|
+
// The run already gave up on this phase and moved on. Its UI node is torn down and
|
|
199
|
+
// its outcome is already reported, so re-drive neither — but work that DID complete
|
|
200
|
+
// is still worth keeping: journal it so a RESUME reuses the output instead of paying
|
|
201
|
+
// for the same phase a second time. A late FAILURE is not journaled — there is
|
|
202
|
+
// nothing to reuse, and a resume has to re-run it anyway.
|
|
203
|
+
if (forced) {
|
|
204
|
+
if (r.ok) deps.journal?.({ phase: p.id, hash: deps.hash, ok: true, output: r.output });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
settled.set(p.id, r);
|
|
208
|
+
deps.onPhase?.(p.id, r.ok ? "done" : "failed", r);
|
|
209
|
+
deps.journal?.({ phase: p.id, hash: deps.hash, ok: r.ok, output: r.output });
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const abort = whenAborted(deps.signal);
|
|
214
|
+
try {
|
|
215
|
+
await Promise.race([wave, abort.promise]);
|
|
216
|
+
if (deps.signal?.aborted) await within(wave, deps.abortGraceMs ?? ABORT_GRACE_MS);
|
|
217
|
+
} finally {
|
|
218
|
+
abort.dispose();
|
|
219
|
+
}
|
|
220
|
+
if (deps.signal?.aborted) {
|
|
221
|
+
forced = true;
|
|
222
|
+
for (const p of runnable) {
|
|
223
|
+
if (settled.has(p.id)) continue;
|
|
224
|
+
// `failureKind: "abort"` is the distinction every other layer keys on (the strategies'
|
|
225
|
+
// own cancelled results carry it) — without it a phase the USER stopped reads to its
|
|
226
|
+
// consumers as one that simply failed.
|
|
227
|
+
const r: AgentResult = { agent: p.id, output: "", usage: emptyUsage(), ok: false, error: CANCELLED_ERROR, failureKind: "abort" };
|
|
228
|
+
settled.set(p.id, r);
|
|
229
|
+
deps.onPhase?.(p.id, "failed", r);
|
|
230
|
+
// Deliberately NOT journaled: the phase produced nothing, so a resume has to re-run
|
|
231
|
+
// it rather than skip it as done.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (const [id, r] of settled) done.set(id, r);
|
|
235
|
+
if (deps.signal?.aborted) {
|
|
236
|
+
// Gates are skipped on the way out — an aborted run must not stop to prompt for a
|
|
237
|
+
// checkpoint nobody is waiting on.
|
|
238
|
+
cancelled = spec.phases.some((p) => done.get(p.id)?.ok !== true);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Resolve checkpoint gates for phases that just completed OK — sequentially, so the
|
|
243
|
+
// next readiness pass sees each gate's verdict (a rejected gate blocks its dependents).
|
|
244
|
+
for (const [id, r] of settled) {
|
|
245
|
+
const phase = byId.get(id);
|
|
246
|
+
if (!phase?.gate || !r.ok || gateState.has(id)) continue;
|
|
247
|
+
const approved = deps.approveGate ? await deps.approveGate(phase, r) : true;
|
|
248
|
+
gateState.set(id, approved ? "passed" : "rejected");
|
|
249
|
+
if (approved) deps.journal?.({ phase: id, hash: deps.hash, ok: true, output: r.output, gateApproved: true });
|
|
250
|
+
}
|
|
251
|
+
remaining = remaining.filter((p) => !done.has(p.id));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const results: Record<string, AgentResult> = Object.fromEntries(done);
|
|
255
|
+
const needed = new Set(spec.phases.flatMap((p) => p.needs ?? []));
|
|
256
|
+
const terminals = spec.phases.filter((p) => !needed.has(p.id));
|
|
257
|
+
const terminalOutput = terminals
|
|
258
|
+
.map((p) => done.get(p.id)?.output ?? "")
|
|
259
|
+
.filter((s) => s.trim())
|
|
260
|
+
.join("\n\n---\n\n");
|
|
261
|
+
const ok = spec.phases.every((p) => done.get(p.id)?.ok ?? false);
|
|
262
|
+
const failures = spec.phases
|
|
263
|
+
.map((phase) => ({ phase, result: done.get(phase.id) }))
|
|
264
|
+
.filter((entry): entry is { phase: FlowPhase; result: AgentResult } => entry.result !== undefined && !entry.result.ok);
|
|
265
|
+
const rootFailures = failures.filter((entry) => !blockedPhaseIds.has(entry.phase.id));
|
|
266
|
+
const cancellation = cancelled;
|
|
267
|
+
// Cancellation is authoritative: a normal failure from an earlier phase must not replace the
|
|
268
|
+
// user's stop with an ordinary failure classification. Prefer an explicitly aborted phase when
|
|
269
|
+
// one settled, otherwise keep the cancellation at flow level without inventing a failed phase.
|
|
270
|
+
const failure = cancellation
|
|
271
|
+
? failures.find((entry) => entry.result.failureKind === "abort")
|
|
272
|
+
: rootFailures[0] ?? failures[0];
|
|
273
|
+
const failedPhase = failure?.phase.id;
|
|
274
|
+
const error = cancellation ? (failure?.result.error ?? CANCELLED_ERROR) : failure?.result.error;
|
|
275
|
+
const failureKind = cancellation ? "abort" as const : failure?.result.failureKind;
|
|
276
|
+
const failureText = failedPhase
|
|
277
|
+
? `Flow failed at phase "${failedPhase}": ${error ?? "the phase failed"}`
|
|
278
|
+
: `Flow failed: ${error ?? "the flow did not complete successfully"}`;
|
|
279
|
+
const cancellationText = `Flow cancelled${failedPhase ? ` at phase "${failedPhase}"` : ""}: ${error ?? CANCELLED_ERROR}`;
|
|
280
|
+
const outputAlreadySignalsCancellation = /\b(?:cancel(?:led|lation)?|abort(?:ed)?|stopp?ed)\b/i.test(terminalOutput);
|
|
281
|
+
const output = cancellation
|
|
282
|
+
? terminalOutput.trim()
|
|
283
|
+
? outputAlreadySignalsCancellation
|
|
284
|
+
? terminalOutput
|
|
285
|
+
: `${terminalOutput}\n\n${cancellationText}`
|
|
286
|
+
: cancellationText
|
|
287
|
+
: terminalOutput.trim()
|
|
288
|
+
? terminalOutput
|
|
289
|
+
: failure
|
|
290
|
+
? failureText
|
|
291
|
+
: terminalOutput;
|
|
292
|
+
return {
|
|
293
|
+
ok,
|
|
294
|
+
results,
|
|
295
|
+
output,
|
|
296
|
+
...(failedPhase ? { failedPhase } : {}),
|
|
297
|
+
...(error ? { error } : {}),
|
|
298
|
+
...(failureKind ? { failureKind } : {}),
|
|
299
|
+
...(cancelled ? { cancelled: true } : {}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flows (v0.5) — a declarative DAG over strategies. A `*.flow.json` file names phases,
|
|
3
|
+
* each running a strategy over a roster, wired by `needs` edges. JSON-first (the magi
|
|
4
|
+
* council's ruling): `JSON.parse` + a hand-rolled validator (no new deps), the DAG
|
|
5
|
+
* pinned per run via the contract hasher and journaled for resume. This module is the
|
|
6
|
+
* PURE core — schema, validation, and topological order; the runner (engine + journal
|
|
7
|
+
* injected) lives alongside in `flow-run.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { stableHash } from "../core/contract.ts";
|
|
11
|
+
|
|
12
|
+
export interface FlowPhase {
|
|
13
|
+
id: string;
|
|
14
|
+
strategy: string;
|
|
15
|
+
roster?: string;
|
|
16
|
+
needs?: string[];
|
|
17
|
+
params?: Record<string, unknown>;
|
|
18
|
+
/** A checkpoint: after this phase completes, require approval before its dependents run. */
|
|
19
|
+
gate?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FlowSpec {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
phases: FlowPhase[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type FlowParse = { ok: true; flow: FlowSpec } | { ok: false; error: string };
|
|
29
|
+
export type TopoResult = { ok: true; order: string[] } | { ok: false; error: string };
|
|
30
|
+
|
|
31
|
+
/** The pinned `flow@hash` for a run (I3): a stable sha256 over the spec, so a hot reload
|
|
32
|
+
* of the file can't change an in-flight run and a resume only matches the same flow. */
|
|
33
|
+
export function flowHash(spec: FlowSpec): string {
|
|
34
|
+
return stableHash(spec);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isObject(x: unknown): x is Record<string, unknown> {
|
|
38
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Kahn's algorithm over `needs` edges → a dependency-respecting order, or a cycle error. */
|
|
42
|
+
export function topoOrder(phases: FlowPhase[]): TopoResult {
|
|
43
|
+
const ids = new Set(phases.map((p) => p.id));
|
|
44
|
+
const indeg = new Map<string, number>();
|
|
45
|
+
const dependents = new Map<string, string[]>(); // a phase → the phases that need it
|
|
46
|
+
for (const p of phases) indeg.set(p.id, 0);
|
|
47
|
+
for (const p of phases) {
|
|
48
|
+
for (const n of p.needs ?? []) {
|
|
49
|
+
if (!ids.has(n)) return { ok: false, error: `phase "${p.id}" needs unknown phase "${n}"` };
|
|
50
|
+
indeg.set(p.id, (indeg.get(p.id) ?? 0) + 1);
|
|
51
|
+
dependents.set(n, [...(dependents.get(n) ?? []), p.id]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const queue = phases.filter((p) => (indeg.get(p.id) ?? 0) === 0).map((p) => p.id);
|
|
55
|
+
const order: string[] = [];
|
|
56
|
+
while (queue.length > 0) {
|
|
57
|
+
const id = queue.shift() as string;
|
|
58
|
+
order.push(id);
|
|
59
|
+
for (const dep of dependents.get(id) ?? []) {
|
|
60
|
+
const d = (indeg.get(dep) ?? 0) - 1;
|
|
61
|
+
indeg.set(dep, d);
|
|
62
|
+
if (d === 0) queue.push(dep);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (order.length !== phases.length) return { ok: false, error: "flow has a cycle" };
|
|
66
|
+
return { ok: true, order };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse + validate a `*.flow.json` into a runnable FlowSpec (or a clear error). The name is
|
|
70
|
+
* taken as the flow's IDENTITY and kept verbatim, path-hostile characters and all: the journal
|
|
71
|
+
* filename it becomes is derived from it (`journalFileName`, flow-journal.ts), not spelled by it. */
|
|
72
|
+
export function parseFlow(content: string): FlowParse {
|
|
73
|
+
let raw: unknown;
|
|
74
|
+
try {
|
|
75
|
+
raw = JSON.parse(content);
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, error: "flow is not valid JSON" };
|
|
78
|
+
}
|
|
79
|
+
if (!isObject(raw)) return { ok: false, error: "flow must be a JSON object" };
|
|
80
|
+
if (typeof raw.name !== "string" || !raw.name.trim()) return { ok: false, error: "flow.name is required" };
|
|
81
|
+
if (!Array.isArray(raw.phases) || raw.phases.length === 0) return { ok: false, error: "flow.phases must be a non-empty array" };
|
|
82
|
+
|
|
83
|
+
const phases: FlowPhase[] = [];
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
for (const [i, p] of (raw.phases as unknown[]).entries()) {
|
|
86
|
+
if (!isObject(p)) return { ok: false, error: `phase ${i} must be an object` };
|
|
87
|
+
if (typeof p.id !== "string" || !p.id.trim()) return { ok: false, error: `phase ${i} needs an id` };
|
|
88
|
+
if (seen.has(p.id)) return { ok: false, error: `duplicate phase id "${p.id}"` };
|
|
89
|
+
if (typeof p.strategy !== "string" || !p.strategy.trim()) return { ok: false, error: `phase "${p.id}" needs a strategy` };
|
|
90
|
+
seen.add(p.id);
|
|
91
|
+
const phase: FlowPhase = { id: p.id, strategy: p.strategy };
|
|
92
|
+
if (typeof p.roster === "string" && p.roster.trim()) phase.roster = p.roster;
|
|
93
|
+
if (Array.isArray(p.needs)) {
|
|
94
|
+
if (!p.needs.every((n): n is string => typeof n === "string")) return { ok: false, error: `phase "${p.id}" needs must be strings` };
|
|
95
|
+
phase.needs = p.needs;
|
|
96
|
+
}
|
|
97
|
+
if (isObject(p.params)) phase.params = p.params;
|
|
98
|
+
if (p.gate === true) phase.gate = true;
|
|
99
|
+
phases.push(phase);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const topo = topoOrder(phases);
|
|
103
|
+
if (!topo.ok) return { ok: false, error: topo.error };
|
|
104
|
+
|
|
105
|
+
const flow: FlowSpec = { name: raw.name, phases };
|
|
106
|
+
if (typeof raw.description === "string") flow.description = raw.description;
|
|
107
|
+
return { ok: true, flow };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Bind a parsed flow to the live strategy registry + team names. `parseFlow` is
|
|
111
|
+
* structural (JSON + DAG); this is the discovery check so a typo cannot start a run. */
|
|
112
|
+
export function verifyFlowRefs(
|
|
113
|
+
flow: FlowSpec,
|
|
114
|
+
known: { strategies: readonly string[]; teams: ReadonlySet<string> },
|
|
115
|
+
): FlowParse {
|
|
116
|
+
for (const phase of flow.phases) {
|
|
117
|
+
if (!known.strategies.includes(phase.strategy)) {
|
|
118
|
+
return { ok: false, error: `unknown strategy "${phase.strategy}"` };
|
|
119
|
+
}
|
|
120
|
+
if (phase.roster && !known.teams.has(phase.roster)) {
|
|
121
|
+
return { ok: false, error: `unknown roster "${phase.roster}"` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, flow };
|
|
125
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judge preparation (§4.3) — the impartial-judge counterpart to `vote`. Voting tallies
|
|
3
|
+
* the candidates' own votes; judging hands the candidates to ONE judge agent that picks.
|
|
4
|
+
* The bias guard is that the judge must see them **anonymised** (no author identity) and,
|
|
5
|
+
* for production, **reordered** (so position can't bias the pick). This module is the PURE
|
|
6
|
+
* part: render an anonymised, labelled ballot and resolve the judge's chosen label back to
|
|
7
|
+
* the real candidate. A judge strategy then runs `agent(judge, { task: prep.ballot })` and
|
|
8
|
+
* maps its verdict via `prep.pick(...)` — no new engine surface needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
12
|
+
import type { AgentResult } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export interface JudgePrep {
|
|
15
|
+
/** The candidates rendered anonymised + reordered, each under a `[A]`/`[B]`… label. */
|
|
16
|
+
ballot: string;
|
|
17
|
+
/** Resolve a judge's chosen label (case-insensitive) back to the original candidate. */
|
|
18
|
+
pick(label: string): AgentResult | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LABELS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The judge's vote reduced to a ballot label. Models routinely answer "Candidate B" or "B."
|
|
25
|
+
* where the ballot asked for "B", so a bare single-letter token is read as the pick.
|
|
26
|
+
*
|
|
27
|
+
* The candidate letters are filtered against the labels ACTUALLY on this ballot, and an
|
|
28
|
+
* ambiguous vote (two different labels named, e.g. "A or B?") resolves to nothing. Guessing
|
|
29
|
+
* here is worse than failing: the no-pick path already returns the panel (or, for compete,
|
|
30
|
+
* every valid diff) — a refusal loses nothing, while a wrong guess applies the wrong work.
|
|
31
|
+
*/
|
|
32
|
+
export function ballotLabel(vote: string, onBallot: number): string {
|
|
33
|
+
const bare = vote.trim().toUpperCase();
|
|
34
|
+
if (bare.length <= 1) return bare;
|
|
35
|
+
const labels = new Set(LABELS.slice(0, Math.min(onBallot, LABELS.length)));
|
|
36
|
+
const named = [...new Set(bare.split(/[^A-Z]+/).filter((t) => t.length === 1 && labels.has(t)))];
|
|
37
|
+
return named.length === 1 ? named[0]! : bare;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPermutation(order: number[], n: number): boolean {
|
|
41
|
+
if (order.length !== n) return false;
|
|
42
|
+
const seen = new Set(order);
|
|
43
|
+
if (seen.size !== n) return false;
|
|
44
|
+
return order.every((i) => Number.isInteger(i) && i >= 0 && i < n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Anonymise + label (+ optionally reorder) candidates for an impartial LLM judge. `order`
|
|
48
|
+
* is a permutation of candidate indices (ballot position → candidate); production passes a
|
|
49
|
+
* shuffle, tests pass a fixed permutation. Defaults to candidate order. A malformed `order`
|
|
50
|
+
* (wrong length / not a permutation) is ignored rather than silently becoming identity —
|
|
51
|
+
* identity would drop the reorder half of the bias guard. */
|
|
52
|
+
export function prepareJudge(candidates: AgentResult[], order?: number[]): JudgePrep {
|
|
53
|
+
const perm = order && isPermutation(order, candidates.length) ? order : candidates.map((_, i) => i);
|
|
54
|
+
const byLabel = new Map<string, AgentResult>();
|
|
55
|
+
const sections: string[] = [];
|
|
56
|
+
perm.forEach((candIdx, pos) => {
|
|
57
|
+
const c = candidates[candIdx];
|
|
58
|
+
if (!c) return;
|
|
59
|
+
const label = LABELS[pos] ?? `#${pos + 1}`;
|
|
60
|
+
byLabel.set(label, c);
|
|
61
|
+
sections.push(`[${label}]\n${fenceUntrusted(c.output.trim())}`);
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
ballot: sections.join("\n\n"),
|
|
65
|
+
pick: (label) => byLabel.get(ballotLabel(label, byLabel.size)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A random permutation of [0..n) — so a judge can't be biased by candidate order.
|
|
70
|
+
* Production ballots pass this to {@link prepareJudge}; tests pass a fixed permutation. */
|
|
71
|
+
export function shuffleOrder(n: number): number[] {
|
|
72
|
+
const a = Array.from({ length: n }, (_, i) => i);
|
|
73
|
+
for (let i = n - 1; i > 0; i--) {
|
|
74
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
75
|
+
[a[i], a[j]] = [a[j] as number, a[i] as number];
|
|
76
|
+
}
|
|
77
|
+
return a;
|
|
78
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer-model retry policy — when one council member fails for a MODEL reason, which model
|
|
3
|
+
* should it be re-run on?
|
|
4
|
+
*
|
|
5
|
+
* This is the roster-level complement to `engine/fallback.ts`. That decorator reroutes the SAME
|
|
6
|
+
* model id to a family-compatible provider (anthropic/x → bedrock/x) and knows nothing about the
|
|
7
|
+
* other members; this decides to hand a broken core a DIFFERENT model that a sibling just proved
|
|
8
|
+
* works in this very session. The two compose: the engine exhausts provider routes first, and only
|
|
9
|
+
* a run that still comes back `provider`/`unknown-model` reaches here.
|
|
10
|
+
*
|
|
11
|
+
* The order of preference is evidence, strongest first:
|
|
12
|
+
* 1. the session's own model, when a peer proved it — the user is running on it, so it is the
|
|
13
|
+
* most likely to be authed, in budget, and available;
|
|
14
|
+
* 2. otherwise the first healthy peer's model in roster order (deterministic, not iteration order);
|
|
15
|
+
* 3. otherwise the session model unproven — the case where the failing core is the first or the
|
|
16
|
+
* only one left, so no peer evidence exists at all.
|
|
17
|
+
*
|
|
18
|
+
* A council's whole value is UNCORRELATED errors from distinct reasoners, so this deliberately
|
|
19
|
+
* never runs as a preference: it is what keeps a broken core from silently dropping out of the
|
|
20
|
+
* vote, and callers are expected to surface it. Pure — no engine, no I/O, no clock.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { AgentResult, FailureKind } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
/** The only failures a DIFFERENT model can fix. `provider` is the provider rejecting or breaking
|
|
26
|
+
* (auth, outage, 5xx, model-not-supported); `unknown-model` is a ref that does not resolve at all.
|
|
27
|
+
* Everything else — a user stop, an idle timeout, a contract violation, the agent's own error —
|
|
28
|
+
* reproduces identically on any model, so retrying only burns tokens. `abort` above all must never
|
|
29
|
+
* retry: a stop that silently respawned work would be a stop that does not stop. */
|
|
30
|
+
export const RETRYABLE_MODEL_FAILURES = ["provider", "unknown-model"] as const satisfies readonly FailureKind[];
|
|
31
|
+
|
|
32
|
+
const retryable = new Set<string>(RETRYABLE_MODEL_FAILURES);
|
|
33
|
+
|
|
34
|
+
export interface ModelRetry {
|
|
35
|
+
/** Position in the round's results, so the caller can re-run exactly this member. */
|
|
36
|
+
index: number;
|
|
37
|
+
agent: string;
|
|
38
|
+
/** The model that just failed, when the engine resolved one. */
|
|
39
|
+
from?: string;
|
|
40
|
+
/** The model to re-run on — never the one that just failed. */
|
|
41
|
+
model: string;
|
|
42
|
+
/** Which evidence chose it, for an honest breadcrumb in the log and the tree. */
|
|
43
|
+
reason: "peer" | "session";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ModelRetryDeps {
|
|
47
|
+
/** The model the user's own session runs on (`provider/id`), when known. */
|
|
48
|
+
sessionModel?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Plan at most one retry per failed member. Returns an empty list when nothing is worth
|
|
53
|
+
* re-running — no model-caused failure, or no model available that differs from the one that
|
|
54
|
+
* already failed.
|
|
55
|
+
*/
|
|
56
|
+
export function planModelRetries(results: readonly AgentResult[], deps: ModelRetryDeps): ModelRetry[] {
|
|
57
|
+
const healthy = results.filter((r) => r.ok && r.modelUsed).map((r) => r.modelUsed as string);
|
|
58
|
+
const session = deps.sessionModel?.trim() || undefined;
|
|
59
|
+
// A peer's model is evidence; the session model is evidence too when a peer used it, and a
|
|
60
|
+
// reasonable guess when nothing survived to prove anything.
|
|
61
|
+
const candidates = session && healthy.includes(session)
|
|
62
|
+
? [session, ...healthy.filter((model) => model !== session)]
|
|
63
|
+
: healthy.length > 0
|
|
64
|
+
? healthy
|
|
65
|
+
: session
|
|
66
|
+
? [session]
|
|
67
|
+
: [];
|
|
68
|
+
|
|
69
|
+
const plan: ModelRetry[] = [];
|
|
70
|
+
results.forEach((result, index) => {
|
|
71
|
+
if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return;
|
|
72
|
+
// Re-running on the model that just failed cannot produce a different outcome. Keep walking:
|
|
73
|
+
// a later healthy peer may still provide a distinct model worth trying.
|
|
74
|
+
const candidate = candidates.find((model) => model !== result.modelUsed);
|
|
75
|
+
if (!candidate) return;
|
|
76
|
+
plan.push({
|
|
77
|
+
index,
|
|
78
|
+
agent: result.agent,
|
|
79
|
+
...(result.modelUsed ? { from: result.modelUsed } : {}),
|
|
80
|
+
model: candidate,
|
|
81
|
+
reason: healthy.includes(candidate) ? "peer" : "session",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return plan;
|
|
85
|
+
}
|