@aeondave/pi-persona 1.17.1 → 1.17.3
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 +59 -28
- package/CHANGELOG.md +234 -0
- package/README.md +16 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +183 -55
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +82 -32
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +15 -10
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +1 -1
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +165 -146
- package/src/engine/child.ts +2 -2
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +12 -7
- package/src/engine/stream.ts +4 -20
- package/src/exocom/install.ts +2 -1
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +505 -241
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -5
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/persona.ts +7 -3
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +310 -280
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +9 -3
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MONITOR_MAX_ARGS, MONITOR_MAX_ARG_CHARS, MONITOR_MAX_ARGV_CHARS,
|
|
3
|
+
MONITOR_MAX_COMMAND_CHARS, MONITOR_MAX_EVENTS, MONITOR_MAX_LABEL_CHARS,
|
|
4
|
+
type MonitorEntry, type MonitorEvent, type MonitorOutput,
|
|
5
|
+
} from "../core/monitor.ts";
|
|
6
|
+
|
|
7
|
+
export const MONITOR_LOG_ENTRY = "pi-persona-monitor-log";
|
|
8
|
+
export const MONITOR_LOG_MAX_STREAM_CHARS = 16 * 1024;
|
|
9
|
+
export const MONITOR_LOG_MAX_TEXT_CHARS = 16 * 1024;
|
|
10
|
+
export const MONITOR_LOG_MAX_RECORDS = 64;
|
|
11
|
+
export const MONITOR_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
export interface MonitorLogRecord {
|
|
14
|
+
version: 1;
|
|
15
|
+
entry: MonitorEntry;
|
|
16
|
+
outcome: "running" | "exit" | "error" | "timeout" | "cancelled" | "ended";
|
|
17
|
+
/** Last semantic event's text (not an interleaved raw stream). */
|
|
18
|
+
text: string;
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
storageWarning?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MonitorArchiveDeps {
|
|
26
|
+
/** Only the owning session's entries; never a global session/history lookup. */
|
|
27
|
+
entries: () => readonly unknown[];
|
|
28
|
+
/** Session-native custom data, NOT a model-visible custom message. */
|
|
29
|
+
append: (customType: string, data: unknown) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BUDGET_WARNING = "Monitor log persistence unavailable: session archive budget exhausted; this log is retained only in bounded runtime history.";
|
|
33
|
+
const APPEND_WARNING = "Monitor log persistence unavailable: appending the session entry failed; this log is retained only in bounded runtime history.";
|
|
34
|
+
const HISTORY_WARNING = "Monitor log history unavailable: owning-session entries could not be read; persistence is disabled to preserve the session budget.";
|
|
35
|
+
|
|
36
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nonempty(value: unknown, limit: number): value is string {
|
|
41
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= limit && !value.includes("\0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function onlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
45
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validEntry(value: unknown): value is MonitorEntry {
|
|
49
|
+
if (!object(value) || !onlyKeys(value, ["id", "label", "command", "args", "cwd", "mode", "startedAtEpochMs", "expiresAtEpochMs", "maxEvents"])) return false;
|
|
50
|
+
return nonempty(value.id, 512)
|
|
51
|
+
&& nonempty(value.label, MONITOR_MAX_LABEL_CHARS)
|
|
52
|
+
&& nonempty(value.command, MONITOR_MAX_COMMAND_CHARS)
|
|
53
|
+
&& nonempty(value.cwd, 32_768)
|
|
54
|
+
&& (value.mode === "output" || value.mode === "exit")
|
|
55
|
+
&& Array.isArray(value.args) && value.args.length <= MONITOR_MAX_ARGS
|
|
56
|
+
&& Array.from(value.args).every((arg: unknown) => typeof arg === "string" && arg.length <= MONITOR_MAX_ARG_CHARS && !arg.includes("\0"))
|
|
57
|
+
&& value.args.reduce((sum: number, arg: string) => sum + arg.length, 0) <= MONITOR_MAX_ARGV_CHARS
|
|
58
|
+
&& typeof value.startedAtEpochMs === "number" && Number.isFinite(value.startedAtEpochMs) && value.startedAtEpochMs >= 0
|
|
59
|
+
&& typeof value.expiresAtEpochMs === "number" && Number.isFinite(value.expiresAtEpochMs) && value.expiresAtEpochMs >= value.startedAtEpochMs
|
|
60
|
+
&& typeof value.maxEvents === "number" && Number.isInteger(value.maxEvents) && value.maxEvents >= 1 && value.maxEvents <= MONITOR_MAX_EVENTS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function copyEntry(entry: MonitorEntry): MonitorEntry {
|
|
64
|
+
return {
|
|
65
|
+
id: entry.id, label: entry.label, command: entry.command, args: [...entry.args],
|
|
66
|
+
cwd: entry.cwd, mode: entry.mode, startedAtEpochMs: entry.startedAtEpochMs,
|
|
67
|
+
expiresAtEpochMs: entry.expiresAtEpochMs, maxEvents: entry.maxEvents,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function copyRecord(record: MonitorLogRecord): MonitorLogRecord {
|
|
72
|
+
return {
|
|
73
|
+
version: 1, entry: copyEntry(record.entry), outcome: record.outcome,
|
|
74
|
+
text: record.text, stdout: record.stdout, stderr: record.stderr, truncated: record.truncated,
|
|
75
|
+
...(record.storageWarning === undefined ? {} : { storageWarning: record.storageWarning }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Validate custom-entry DATA for historical operator rendering; never returns input references.
|
|
80
|
+
* Unknown fields are rejected so serialized metadata cannot hide outside the archive budget. */
|
|
81
|
+
export function parseMonitorLogRecord(value: unknown): MonitorLogRecord | undefined {
|
|
82
|
+
try {
|
|
83
|
+
return parseRecord(value);
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseRecord(value: unknown): MonitorLogRecord | undefined {
|
|
90
|
+
if (!object(value) || !onlyKeys(value, ["version", "entry", "outcome", "text", "stdout", "stderr", "truncated", "storageWarning"])
|
|
91
|
+
|| value.version !== 1 || !validEntry(value.entry)) return undefined;
|
|
92
|
+
if (value.outcome !== "exit" && value.outcome !== "error" && value.outcome !== "timeout"
|
|
93
|
+
&& value.outcome !== "cancelled" && value.outcome !== "ended") return undefined;
|
|
94
|
+
if (typeof value.text !== "string" || value.text.length > MONITOR_LOG_MAX_TEXT_CHARS
|
|
95
|
+
|| typeof value.stdout !== "string" || value.stdout.length > MONITOR_LOG_MAX_STREAM_CHARS
|
|
96
|
+
|| typeof value.stderr !== "string" || value.stderr.length > MONITOR_LOG_MAX_STREAM_CHARS
|
|
97
|
+
|| typeof value.truncated !== "boolean"
|
|
98
|
+
|| (value.storageWarning !== undefined && (typeof value.storageWarning !== "string" || value.storageWarning.length > 1024))) return undefined;
|
|
99
|
+
return copyRecord(value as unknown as MonitorLogRecord);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** UTF-8 uses at most three bytes per UTF-16 code unit, AFTER JSON escaping.
|
|
103
|
+
* Reserve extra space for the host's custom-entry envelope. Deliberately conservative:
|
|
104
|
+
* this bounds the archive's serialized records, not arbitrary host/session metadata. */
|
|
105
|
+
function storageCost(record: unknown): number {
|
|
106
|
+
return (JSON.stringify(record) ?? "null").length * 3 + 256;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Bounded, session-native raw logs. No files, processes, timers, wakes, or model messages.
|
|
110
|
+
* Feed only admitted MonitorManager lifecycles (at most MONITOR_MAX_ACTIVE running entries).
|
|
111
|
+
* Completed records are oldest-first; the most recent 64 survive in runtime memory.
|
|
112
|
+
* Hydration runs once, so construct a fresh archive when the owning session changes.
|
|
113
|
+
* History never restores running monitors. A removed observer has no cause information:
|
|
114
|
+
* cancellation, permission revocation, shutdown and synchronous start failure all map to cancelled.
|
|
115
|
+
*/
|
|
116
|
+
export class MonitorArchive {
|
|
117
|
+
private readonly deps: MonitorArchiveDeps;
|
|
118
|
+
private readonly active = new Map<string, MonitorLogRecord>();
|
|
119
|
+
private readonly completed = new Map<string, MonitorLogRecord>();
|
|
120
|
+
private hydrated = false;
|
|
121
|
+
private persistedCount = 0;
|
|
122
|
+
private persistedBytes = 0;
|
|
123
|
+
private historyWarning: string | undefined;
|
|
124
|
+
|
|
125
|
+
constructor(deps: MonitorArchiveDeps) {
|
|
126
|
+
this.deps = deps;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
armed(entry: MonitorEntry): void {
|
|
130
|
+
this.hydrate();
|
|
131
|
+
if (this.active.has(entry.id) || this.completed.has(entry.id)) return;
|
|
132
|
+
this.active.set(entry.id, {
|
|
133
|
+
version: 1, entry: copyEntry(entry), outcome: "running", text: "", stdout: "", stderr: "", truncated: false,
|
|
134
|
+
...(this.historyWarning === undefined ? {} : { storageWarning: this.historyWarning }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
output({ id, stream, text }: MonitorOutput): void {
|
|
139
|
+
const record = this.active.get(id);
|
|
140
|
+
if (!record || !text) return;
|
|
141
|
+
const current = record[stream];
|
|
142
|
+
if (current.length + text.length > MONITOR_LOG_MAX_STREAM_CHARS) record.truncated = true;
|
|
143
|
+
// Slice the chunk BEFORE concatenation: a huge callback must not create a huge copy.
|
|
144
|
+
record[stream] = text.length >= MONITOR_LOG_MAX_STREAM_CHARS
|
|
145
|
+
? text.slice(-MONITOR_LOG_MAX_STREAM_CHARS)
|
|
146
|
+
: (current + text).slice(-MONITOR_LOG_MAX_STREAM_CHARS);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
event(event: MonitorEvent): void {
|
|
150
|
+
const record = this.active.get(event.id);
|
|
151
|
+
if (!record) return;
|
|
152
|
+
record.text = event.text.slice(0, MONITOR_LOG_MAX_TEXT_CHARS);
|
|
153
|
+
if (event.text.length > MONITOR_LOG_MAX_TEXT_CHARS) record.truncated = true;
|
|
154
|
+
if (!event.terminal) return;
|
|
155
|
+
record.outcome = event.kind === "output" ? "ended" : event.kind;
|
|
156
|
+
this.finish(record);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
removed(entry: MonitorEntry): void {
|
|
160
|
+
const record = this.active.get(entry.id);
|
|
161
|
+
if (!record) return;
|
|
162
|
+
record.outcome = "cancelled";
|
|
163
|
+
this.finish(record);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
result(id: string): MonitorLogRecord | undefined {
|
|
167
|
+
this.hydrate();
|
|
168
|
+
const record = this.active.get(id) ?? this.completed.get(id);
|
|
169
|
+
return record ? copyRecord(record) : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
list(): MonitorLogRecord[] {
|
|
173
|
+
this.hydrate();
|
|
174
|
+
return [...this.completed.values(), ...this.active.values()].map(copyRecord);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private hydrate(): void {
|
|
178
|
+
if (this.hydrated) return;
|
|
179
|
+
this.hydrated = true;
|
|
180
|
+
try {
|
|
181
|
+
const entries = this.deps.entries();
|
|
182
|
+
if (!Array.isArray(entries)) throw new Error("invalid session entries");
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
try {
|
|
185
|
+
if (!object(entry) || entry.type !== "custom" || entry.customType !== MONITOR_LOG_ENTRY) continue;
|
|
186
|
+
// Charge even unknown/malformed versions and duplicate ids: ignoring a record
|
|
187
|
+
// for retrieval does not remove its bytes from an append-only session.
|
|
188
|
+
this.persistedCount++;
|
|
189
|
+
this.persistedBytes += storageCost(entry.data);
|
|
190
|
+
const record = parseMonitorLogRecord(entry.data);
|
|
191
|
+
if (record) this.remember(record);
|
|
192
|
+
} catch {
|
|
193
|
+
// An unaccountable entry cannot safely reset the lifetime budget.
|
|
194
|
+
this.historyWarning = HISTORY_WARNING;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Without a trustworthy baseline, appending could silently overrun the session quota.
|
|
199
|
+
this.historyWarning = HISTORY_WARNING;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private finish(record: MonitorLogRecord): void {
|
|
204
|
+
// Settle BEFORE calling injected persistence; duplicate/reentrant callbacks are inert.
|
|
205
|
+
this.active.delete(record.entry.id);
|
|
206
|
+
this.remember(record);
|
|
207
|
+
if (this.historyWarning !== undefined) {
|
|
208
|
+
record.storageWarning = this.historyWarning;
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const cost = storageCost(record);
|
|
212
|
+
if (this.persistedCount >= MONITOR_LOG_MAX_RECORDS || this.persistedBytes + cost > MONITOR_LOG_MAX_BYTES) {
|
|
213
|
+
record.storageWarning = BUDGET_WARNING;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Reserve even when append throws: it may have committed before reporting failure.
|
|
217
|
+
// This also bounds repeated failing append attempts over the runtime's lifetime.
|
|
218
|
+
this.persistedCount++;
|
|
219
|
+
this.persistedBytes += cost;
|
|
220
|
+
try {
|
|
221
|
+
this.deps.append(MONITOR_LOG_ENTRY, copyRecord(record));
|
|
222
|
+
} catch {
|
|
223
|
+
record.storageWarning = APPEND_WARNING;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private remember(record: MonitorLogRecord): void {
|
|
228
|
+
this.completed.delete(record.entry.id);
|
|
229
|
+
this.completed.set(record.entry.id, record);
|
|
230
|
+
while (this.completed.size > MONITOR_LOG_MAX_RECORDS) {
|
|
231
|
+
const oldest = this.completed.keys().next().value;
|
|
232
|
+
if (oldest !== undefined) this.completed.delete(oldest);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/monitor/session.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
/** Monitor events use the same idle follow-up delivery as timer and Intercom. */
|
|
2
|
-
import { MonitorManager, type MonitorEvent } from "../core/monitor.ts";
|
|
2
|
+
import { MonitorManager, type MonitorEntry, type MonitorEvent, type MonitorOutput } from "../core/monitor.ts";
|
|
3
3
|
import { fenceUntrusted, stripTerminalControls } from "../core/fence.ts";
|
|
4
4
|
import { IdleCoalescingNotifier } from "../engine/async.ts";
|
|
5
5
|
import { startMonitorProcess } from "./process.ts";
|
|
6
6
|
|
|
7
|
+
/** A passive view of the monitor lifecycle (the agent tree's monitor rows). Every `armed` is followed
|
|
8
|
+
* by exactly one of: a terminal `event`, or `removed` (cancel, permission loss, shutdown, start failure).
|
|
9
|
+
* Observation never changes admission or delivery. */
|
|
10
|
+
export interface MonitorObserver {
|
|
11
|
+
armed(entry: MonitorEntry): void;
|
|
12
|
+
event(event: MonitorEvent): void;
|
|
13
|
+
removed(entry: MonitorEntry): void;
|
|
14
|
+
/** Raw process output for bounded local views/storage only; never a wake. */
|
|
15
|
+
output?(output: MonitorOutput): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
interface MonitorSessionDeps {
|
|
19
|
+
/** Activation-unique prefix when results are persisted across reloads. */
|
|
20
|
+
idPrefix?: string;
|
|
8
21
|
isIdle(): boolean;
|
|
9
22
|
canRun(): boolean;
|
|
10
23
|
deliver(text: string): void;
|
|
11
24
|
start?: typeof startMonitorProcess;
|
|
25
|
+
observe?: MonitorObserver;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
export function createMonitorSession(d: MonitorSessionDeps) {
|
|
@@ -31,7 +45,13 @@ export function createMonitorSession(d: MonitorSessionDeps) {
|
|
|
31
45
|
});
|
|
32
46
|
const manager = new MonitorManager({
|
|
33
47
|
...timerDeps, now: () => Date.now(), start: d.start ?? startMonitorProcess,
|
|
48
|
+
...(d.idPrefix ? { idPrefix: d.idPrefix } : {}),
|
|
49
|
+
...(d.observe ? { onArmed: (entry: MonitorEntry) => d.observe?.armed(entry), onRemoved: (entry: MonitorEntry) => d.observe?.removed(entry) } : {}),
|
|
50
|
+
onOutput: (output) => { d.observe?.output?.(output); },
|
|
34
51
|
onEvent: (event) => {
|
|
52
|
+
// Observed BEFORE the permission gate: a terminal event has already left the manager, so the
|
|
53
|
+
// `cancelAll` below could never announce it, and its row would stay on screen forever.
|
|
54
|
+
d.observe?.event(event);
|
|
35
55
|
if (!d.canRun()) { cancelAll(); return; }
|
|
36
56
|
const oldest = notifier.peekPending()[0];
|
|
37
57
|
if (notifier.peekPending().length >= 64 && oldest) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Flow journal (v0.5) — JSONL resume. Each completed phase appends one line; on a later
|
|
3
3
|
* run the journal is read back into a resume map (phase id → output) so done phases are
|
|
4
|
-
* skipped. Lines whose `hash` doesn't match the current
|
|
5
|
-
*
|
|
4
|
+
* skipped. Lines whose `hash` doesn't match the current run hash (`flowRunHash`: flow + task +
|
|
5
|
+
* cwd) are ignored (another flow version, task or project), and a phase recorded `ok:false` is
|
|
6
|
+
* NOT resumed (it re-runs). `parseJournal`
|
|
6
7
|
* and the filename derivation are pure (tested); the read/write helpers are thin `fs` glue.
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -24,8 +25,9 @@ const PATH_HOSTILE = /[/\\<>:"|?*\x00-\x1f]/g;
|
|
|
24
25
|
* name as a filename can be; the `flow@hash` half is what actually keeps journals apart, since
|
|
25
26
|
* it covers the whole spec (the name included). Two names that encode alike — `ci:quick` and
|
|
26
27
|
* `ci?quick` — are therefore still two different specs with two different hashes and two
|
|
27
|
-
* different files, and so is a name long enough to be truncated here.
|
|
28
|
-
*
|
|
28
|
+
* different files, and so is a name long enough to be truncated here. Runs of ONE flow on another
|
|
29
|
+
* task or project share this file, and a truncated hash could collide: either way every LINE
|
|
30
|
+
* carries the full run hash (`flowRunHash`), so a resume reads only its own. */
|
|
29
31
|
export function journalFileName(spec: FlowSpec): string {
|
|
30
32
|
return `${encodeFlowName(spec.name)}.${flowHash(spec).slice(0, 8)}.journal.jsonl`;
|
|
31
33
|
}
|
|
@@ -38,7 +40,7 @@ function encodeFlowName(name: string): string {
|
|
|
38
40
|
return encoded || "flow";
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
/** Fold a JSONL journal into a resume map for the given
|
|
43
|
+
/** Fold a JSONL journal into a resume map for the given run hash. Later lines win, so a
|
|
42
44
|
* phase that failed then succeeded ends up resumed; a phase still failed is left to re-run.
|
|
43
45
|
* A `gateApproved` line marks a checkpoint already approved so a resume doesn't re-prompt. */
|
|
44
46
|
export function parseJournal(content: string, hash: string): Record<string, ResumedResult> {
|
|
@@ -74,6 +76,25 @@ export function readJournal(path: string, hash: string): Record<string, ResumedR
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/** A finished run leaves the file: drop ITS lines (unreadable ones too), keep every other run's
|
|
80
|
+
* resumable progress, and remove the file once nothing is left. Best-effort. */
|
|
81
|
+
export function clearJournal(path: string, hash: string): void {
|
|
82
|
+
try {
|
|
83
|
+
const others = fs.readFileSync(path, "utf8").split("\n").filter((line) => {
|
|
84
|
+
try {
|
|
85
|
+
const e = JSON.parse(line) as FlowJournalEntry | null;
|
|
86
|
+
return !!e && typeof e === "object" && e.hash !== hash;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (others.length === 0) fs.rmSync(path, { force: true });
|
|
92
|
+
else fs.writeFileSync(path, `${others.join("\n")}\n`);
|
|
93
|
+
} catch {
|
|
94
|
+
/* best effort — a journal that cannot be cleaned is only a stale resume candidate */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/** An appending writer — one JSONL line per completed phase. Best-effort. */
|
|
78
99
|
export function journalWriter(path: string): (entry: FlowJournalEntry) => void {
|
|
79
100
|
return (entry) => {
|
|
@@ -40,7 +40,7 @@ export type ResumedResult = AgentResult & { gateApproved?: boolean };
|
|
|
40
40
|
export interface FlowRunDeps {
|
|
41
41
|
/** Run one phase's strategy → its result (wraps `runPersonaStrategy` in prod). */
|
|
42
42
|
runPhase: RunPhaseFn;
|
|
43
|
-
/** The pinned `
|
|
43
|
+
/** The pinned run identity (I3: `flowRunHash` = flow + task + cwd) — recorded in every journal line. */
|
|
44
44
|
hash: string;
|
|
45
45
|
signal?: AbortSignal;
|
|
46
46
|
/** Grace an aborted wave gets to unwind cooperatively before its still-running phases are
|
|
@@ -71,6 +71,17 @@ export interface FlowOutcome {
|
|
|
71
71
|
/** The run signal fired before every phase settled ok. A cancelled flow is NOT the same as one
|
|
72
72
|
* that merely finished without an answer, and callers report it differently. */
|
|
73
73
|
cancelled?: boolean;
|
|
74
|
+
/** The first checkpoint (in spec order) that was not approved, when that is the ONLY reason the
|
|
75
|
+
* flow did not finish: a stop the operator chose, not a failure. `ok` stays false and `error`
|
|
76
|
+
* stays "blocked: …" (a mandatory flow must keep failing closed); `output` is the gated
|
|
77
|
+
* phase's own answer plus a stop note. */
|
|
78
|
+
stoppedAtGate?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a finished flow IS to a reader. A checkpoint the operator declined is a STOP, their choice, never a
|
|
82
|
+
* failure: the one predicate the tool result, the tree row and the cards share. */
|
|
83
|
+
export function flowDisposition(outcome: { ok: boolean; stoppedAtGate?: string | undefined }): "done" | "stopped" | "failed" {
|
|
84
|
+
return outcome.ok ? "done" : outcome.stoppedAtGate !== undefined ? "stopped" : "failed";
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
/** How long an aborted wave is given to unwind COOPERATIVELY before its still-running phases are
|
|
@@ -270,6 +281,12 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
|
|
|
270
281
|
const failure = cancellation
|
|
271
282
|
? failures.find((entry) => entry.result.failureKind === "abort")
|
|
272
283
|
: rootFailures[0] ?? failures[0];
|
|
284
|
+
// A rejected checkpoint is a STOP, not a failure, only when nothing else went wrong: a real phase
|
|
285
|
+
// failure or a cancellation is the more important fact and keeps its ordinary report. A rejected
|
|
286
|
+
// gate with no dependents blocks nothing, so the flow is still `ok` and was not stopped.
|
|
287
|
+
const stoppedAtGate = !cancellation && !ok && rootFailures.length === 0
|
|
288
|
+
? spec.phases.find((p) => gateState.get(p.id) === "rejected")?.id
|
|
289
|
+
: undefined;
|
|
273
290
|
const failedPhase = failure?.phase.id;
|
|
274
291
|
const error = cancellation ? (failure?.result.error ?? CANCELLED_ERROR) : failure?.result.error;
|
|
275
292
|
const failureKind = cancellation ? "abort" as const : failure?.result.failureKind;
|
|
@@ -278,24 +295,32 @@ export async function runFlow(spec: FlowSpec, baseTask: string, deps: FlowRunDep
|
|
|
278
295
|
: `Flow failed: ${error ?? "the flow did not complete successfully"}`;
|
|
279
296
|
const cancellationText = `Flow cancelled${failedPhase ? ` at phase "${failedPhase}"` : ""}: ${error ?? CANCELLED_ERROR}`;
|
|
280
297
|
const outputAlreadySignalsCancellation = /\b(?:cancel(?:led|lation)?|abort(?:ed)?|stopp?ed)\b/i.test(terminalOutput);
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
298
|
+
const terminalReport = (): string => {
|
|
299
|
+
// A rejected checkpoint answers with the gated phase's own output: that is what the operator was
|
|
300
|
+
// asked to approve, and dropping it on a "no" would throw away work already paid for. Another
|
|
301
|
+
// branch that finished keeps its answer beside it.
|
|
302
|
+
if (stoppedAtGate !== undefined) {
|
|
303
|
+
return [
|
|
304
|
+
done.get(stoppedAtGate)?.output ?? "",
|
|
305
|
+
terminalOutput,
|
|
306
|
+
`- flow stopped at checkpoint "${stoppedAtGate}": not approved (declined, dismissed, or no interactive session). Later phases did not run.`,
|
|
307
|
+
].filter((part) => part.trim()).join("\n\n");
|
|
308
|
+
}
|
|
309
|
+
if (cancellation) {
|
|
310
|
+
if (!terminalOutput.trim()) return cancellationText;
|
|
311
|
+
return outputAlreadySignalsCancellation ? terminalOutput : `${terminalOutput}\n\n${cancellationText}`;
|
|
312
|
+
}
|
|
313
|
+
if (terminalOutput.trim()) return terminalOutput;
|
|
314
|
+
return failure ? failureText : terminalOutput;
|
|
315
|
+
};
|
|
292
316
|
return {
|
|
293
317
|
ok,
|
|
294
318
|
results,
|
|
295
|
-
output,
|
|
319
|
+
output: terminalReport(),
|
|
296
320
|
...(failedPhase ? { failedPhase } : {}),
|
|
297
321
|
...(error ? { error } : {}),
|
|
298
322
|
...(failureKind ? { failureKind } : {}),
|
|
299
323
|
...(cancelled ? { cancelled: true } : {}),
|
|
324
|
+
...(stoppedAtGate !== undefined ? { stoppedAtGate } : {}),
|
|
300
325
|
};
|
|
301
326
|
}
|
|
@@ -28,12 +28,19 @@ export interface FlowSpec {
|
|
|
28
28
|
export type FlowParse = { ok: true; flow: FlowSpec } | { ok: false; error: string };
|
|
29
29
|
export type TopoResult = { ok: true; order: string[] } | { ok: false; error: string };
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* of the file can't change an in-flight run
|
|
31
|
+
/** A stable sha256 over the spec — the flow half of a run's identity, and the journal file name's
|
|
32
|
+
* hash (`journalFileName`): a hot reload of the file can't change an in-flight run. */
|
|
33
33
|
export function flowHash(spec: FlowSpec): string {
|
|
34
34
|
return stableHash(spec);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The pinned run identity (I3) recorded in every journal line: flow + task + cwd. A resume
|
|
38
|
+
* skips finished phases only for the SAME flow on the SAME task in the SAME project; a failed
|
|
39
|
+
* `flow X` on task A must never feed its phases' output to `flow X` on task B. */
|
|
40
|
+
export function flowRunHash(spec: FlowSpec, task: string, cwd: string): string {
|
|
41
|
+
return stableHash({ flow: flowHash(spec), task: task.trim(), cwd });
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
function isObject(x: unknown): x is Record<string, unknown> {
|
|
38
45
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
39
46
|
}
|
|
@@ -89,21 +89,25 @@ export function itemLedger(
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** Combine fan-out results into one labeled-section result; ok iff all ok.
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
/** Combine fan-out results into one labeled-section result; ok iff all ok. `labels` (aligned to
|
|
93
|
+
* `results`) name each section by lens or item instead of the bare agent; the label is kept as
|
|
94
|
+
* `member` only when it adds something, so a label-free call is byte-identical to before. */
|
|
95
|
+
export function aggregateResults(results: AgentResult[], labels?: readonly string[]): AgentResult {
|
|
96
|
+
const labelOf = (r: AgentResult, i: number): string => labels?.[i] ?? r.agent;
|
|
97
|
+
const sections = results.map((r, i) => {
|
|
95
98
|
const status = r.ok ? "ok" : `failed${r.failureKind ? ` (${r.failureKind})` : ""}`;
|
|
96
99
|
const body = r.output || r.error || "(no output)";
|
|
97
100
|
const cause = !r.ok && r.error && r.output && r.error !== r.output ? `\n\nError: ${r.error}` : "";
|
|
98
|
-
return `### [${r
|
|
101
|
+
return `### [${labelOf(r, i)}] ${status}\n\n${body}${cause}`;
|
|
99
102
|
});
|
|
100
103
|
const aggregate: AgentResult = {
|
|
101
104
|
agent: "aggregate",
|
|
102
105
|
output: sections.join("\n\n---\n\n"),
|
|
103
106
|
structured: {
|
|
104
107
|
count: results.length,
|
|
105
|
-
results: results.map((r) => ({
|
|
108
|
+
results: results.map((r, i) => ({
|
|
106
109
|
agent: r.agent,
|
|
110
|
+
...(labelOf(r, i) !== r.agent ? { member: labelOf(r, i) } : {}),
|
|
107
111
|
ok: r.ok,
|
|
108
112
|
output: r.output,
|
|
109
113
|
error: r.error ?? null,
|
|
@@ -72,7 +72,7 @@ export function humanizeAggregateResult(r: AgentResult): string | undefined {
|
|
|
72
72
|
for (const value of members) {
|
|
73
73
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
74
74
|
const member = value as Record<string, unknown>;
|
|
75
|
-
const agent =
|
|
75
|
+
const agent = field(member, "member") ?? field(member, "agent") ?? "member";
|
|
76
76
|
const ok = member.ok === true;
|
|
77
77
|
const output = typeof member.output === "string" ? member.output : "";
|
|
78
78
|
const structured = member.structured && typeof member.structured === "object" && !Array.isArray(member.structured)
|
package/src/orchestration/sdk.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { RunLimits } from "../core/capabilities.ts";
|
|
9
|
-
import { emptyUsage, type ToolEvent } from "../engine/stream.ts";
|
|
9
|
+
import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
|
|
10
10
|
import { type JudgePrep, prepareJudge } from "./judge.ts";
|
|
11
11
|
import { planModelRecovery } from "./model-retry.ts";
|
|
12
12
|
import { mapWithConcurrency, Semaphore } from "./parallel.ts";
|
|
@@ -53,7 +53,7 @@ export interface AgentRunSpec {
|
|
|
53
53
|
|
|
54
54
|
/** Is `x` a genuine positive, finite `timeoutMs` override — vs. absent/junk (undefined, NaN,
|
|
55
55
|
* Infinity, ≤0), which means "no override, fall back to the run's existing default"? The
|
|
56
|
-
* ONE predicate every `timeoutMs` site gates on (`specOf`, the single-
|
|
56
|
+
* ONE predicate every `timeoutMs` site gates on (`specOf`, the single-leg delegate path,
|
|
57
57
|
* and both engine adapters), so they can't drift into checking it differently. */
|
|
58
58
|
export function isPositiveFiniteMs(x: number | undefined): x is number {
|
|
59
59
|
return x !== undefined && Number.isFinite(x) && x > 0;
|
|
@@ -65,10 +65,10 @@ export interface AgentProgress {
|
|
|
65
65
|
output: string;
|
|
66
66
|
/** Tokens consumed so far (best-effort). */
|
|
67
67
|
tokens?: number;
|
|
68
|
+
/** Copied cumulative counters for display, when supplied by the engine. */
|
|
69
|
+
usage?: ChildUsage;
|
|
68
70
|
/** The tool the agent is currently running (e.g. "grep src/…"), if any. */
|
|
69
71
|
activity?: string;
|
|
70
|
-
/** Timestamp of newly streamed tool output; no tool output text crosses this seam. */
|
|
71
|
-
toolOutputAt?: number;
|
|
72
72
|
/** One authoritative runtime tool lifecycle transition; never contains tool args or output. */
|
|
73
73
|
toolEvent?: ToolEvent;
|
|
74
74
|
}
|
|
@@ -107,7 +107,8 @@ export interface StrategySDK {
|
|
|
107
107
|
agent(spec: AgentRunSpec, options?: AgentCallOptions): Promise<AgentResult>;
|
|
108
108
|
parallel<T>(thunks: Array<() => Promise<T>>, opts?: { concurrency?: number }): Promise<T[]>;
|
|
109
109
|
reduce: {
|
|
110
|
-
|
|
110
|
+
/** `labels` (aligned to `results`) name each section by lens or item instead of the agent. */
|
|
111
|
+
aggregate(results: AgentResult[], labels?: readonly string[]): AgentResult;
|
|
111
112
|
vote(candidates: AgentResult[], opts: VoteOpts): ReducerResult;
|
|
112
113
|
/** Anonymise + label candidates for an impartial judge (run `agent(judge, …)` on
|
|
113
114
|
* the returned ballot, then map the verdict via `pick`). */
|
|
@@ -146,6 +147,17 @@ export interface StrategyParam {
|
|
|
146
147
|
|
|
147
148
|
export interface Strategy {
|
|
148
149
|
name: string;
|
|
150
|
+
/** The problem class this method answers, in one line — rendered into the council tool's
|
|
151
|
+
* description from the registry, so the guidance cannot drift from the code. */
|
|
152
|
+
when?: string;
|
|
153
|
+
/** What the roster positions MEAN, where order or role matters (`[splitter, worker]`). */
|
|
154
|
+
roster?: string;
|
|
155
|
+
/** The team this method is built for. A call that switches TO this method and names no roster seats it,
|
|
156
|
+
* instead of the profile's roster (the wrong panel for it) or the `magi` fallback (read-only voters). */
|
|
157
|
+
team?: string;
|
|
158
|
+
/** Param keys the run cannot proceed without; a call that omits one is refused up front,
|
|
159
|
+
* before any run, tree row or model picker exists. Each must also be declared in `params`. */
|
|
160
|
+
required?: readonly string[];
|
|
149
161
|
/** Declared, discoverable params (name → type/default/doc). Consumed by the council tool
|
|
150
162
|
* (warn on unknown keys) and `/doctor`. A strategy with no params omits this. */
|
|
151
163
|
params?: Record<string, StrategyParam>;
|
|
@@ -62,8 +62,12 @@ function clip(diff: string, max: number): string {
|
|
|
62
62
|
|
|
63
63
|
export const compete: Strategy = {
|
|
64
64
|
name: "compete",
|
|
65
|
+
when: "rival implementations in worktrees, blind-judged; returns the winning diff (clean git repo)",
|
|
66
|
+
roster: "competitors",
|
|
67
|
+
team: "build",
|
|
68
|
+
required: ["judge"],
|
|
65
69
|
params: {
|
|
66
|
-
judge: { type: "agent", doc: "
|
|
70
|
+
judge: { type: "agent", doc: "the arbiter agent, or an inline { agent, role?, model?, skills? }" },
|
|
67
71
|
ballotDiffChars: { type: "number", default: 6000, doc: "ballot-only diff clip length; the winner's diff is always full" },
|
|
68
72
|
},
|
|
69
73
|
async run(input, sdk) {
|
|
@@ -78,6 +78,8 @@ function cancelled(rounds: number, usages: AgentResult["usage"][]): AgentResult
|
|
|
78
78
|
|
|
79
79
|
export const councilRounds: Strategy = {
|
|
80
80
|
name: "council-rounds",
|
|
81
|
+
when: "hard fork needing iterated debate: re-vote each round to a bestOf majority",
|
|
82
|
+
team: "magi",
|
|
81
83
|
params: {
|
|
82
84
|
rounds: { type: "number", default: 3, doc: "max deliberation rounds" },
|
|
83
85
|
bestOf: { type: "number", doc: "default: majority of the roster" },
|
|
@@ -46,6 +46,9 @@ function cancelled(rounds: number, output: string, usages: AgentResult["usage"][
|
|
|
46
46
|
|
|
47
47
|
export const criticLoop: Strategy = {
|
|
48
48
|
name: "critic-loop",
|
|
49
|
+
when: "ONE draft + adversarial critique, revised until the critic approves",
|
|
50
|
+
roster: "[generator, critic]",
|
|
51
|
+
team: "repair",
|
|
49
52
|
params: {
|
|
50
53
|
// `default` must name an agent that ACTUALLY runs (the collector resolves the participant
|
|
51
54
|
// as: the provided override, else roster[rosterIndex], else this agent) — never a phrase
|
|
@@ -84,6 +84,8 @@ function cancelled(usages: AgentResult["usage"][]): AgentResult {
|
|
|
84
84
|
|
|
85
85
|
export const debate: Strategy = {
|
|
86
86
|
name: "debate",
|
|
87
|
+
when: "2+ members argue live with each other, then a majority vote",
|
|
88
|
+
team: "magi",
|
|
87
89
|
params: {
|
|
88
90
|
bestOf: { type: "number", doc: "default: majority of the roster" },
|
|
89
91
|
aggregate: { type: "string", default: "majority", doc: '"majority" | "unanimity"' },
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
* No peers: fanout is deliberately independent parallel work — use `debate` for a live exchange.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { rosterSpec } from "../roster.ts";
|
|
8
|
+
import { rosterNodeKeys, rosterSpec } from "../roster.ts";
|
|
9
9
|
import type { Strategy } from "../sdk.ts";
|
|
10
10
|
|
|
11
11
|
export const fanout: Strategy = {
|
|
12
12
|
name: "fanout",
|
|
13
|
+
when: "ONE question to a panel of distinct lenses/models, answers side by side, no vote (separate tasks → delegate)",
|
|
13
14
|
async run(input, sdk) {
|
|
14
15
|
const team = input.roster ? sdk.roster.team(input.roster) : [];
|
|
15
16
|
if (team.length === 0) throw new Error("fanout: a non-empty roster is required");
|
|
16
17
|
sdk.log(`fanout: ${team.length} agents on "${input.task.slice(0, 40)}"`);
|
|
17
18
|
const results = await sdk.parallel(team.map((m) => () => sdk.agent({ ...rosterSpec(m), task: input.task })));
|
|
18
|
-
return { ...sdk.reduce.aggregate(results), agent: "fanout" };
|
|
19
|
+
return { ...sdk.reduce.aggregate(results, rosterNodeKeys(team)), agent: "fanout" };
|
|
19
20
|
},
|
|
20
21
|
};
|
|
@@ -44,8 +44,12 @@ function cancelled(arbiter: string, display: AgentResult[], usage: AgentResult["
|
|
|
44
44
|
|
|
45
45
|
export const judge: Strategy = {
|
|
46
46
|
name: "judge",
|
|
47
|
+
when: "rival COMPLETE answers: independent panel, one impartial arbiter picks",
|
|
48
|
+
roster: "the panel",
|
|
49
|
+
team: "magi",
|
|
50
|
+
required: ["judge"],
|
|
47
51
|
params: {
|
|
48
|
-
judge: { type: "agent", doc: "
|
|
52
|
+
judge: { type: "agent", doc: "the arbiter agent, or an inline { agent, role?, model?, skills? }" },
|
|
49
53
|
contract: { type: "string", doc: "optional output contract the panel runs against" },
|
|
50
54
|
},
|
|
51
55
|
async run(input, sdk) {
|