paseo-bm-plugin 0.0.0-placeholder.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback incidents: one record per agent turn that ended on a provider-plan
|
|
3
|
+
* failure (delta 20260921 §4.4.2, §4.4.5, REQ-065 a, g, i, j).
|
|
4
|
+
*
|
|
5
|
+
* `registerFallbackDetection` adds one `agent.turn_ended` handler. For a turn
|
|
6
|
+
* `classifyTurn` recognises (L1, L2, L4, L5) it builds the incident:
|
|
7
|
+
*
|
|
8
|
+
* 1. dedupe — an agent with a `pending`, `waiting` or `switched` incident gets
|
|
9
|
+
* no second one;
|
|
10
|
+
* 2. usage — only for L1, on base provider `claude` or `codex`, with the role's
|
|
11
|
+
* policy not `off`: ONE `providers.listUsage()` within 5 s (owner decision
|
|
12
|
+
* Q2 a: the daemon calls the provider's usage API only AFTER a detected
|
|
13
|
+
* failure). `resetsAt` is the latest reset among the exhausted windows;
|
|
14
|
+
* `perModelWindow` is true when every exhausted window is per model;
|
|
15
|
+
* 3. candidate — the first entry after the failed agent's position in the
|
|
16
|
+
* role's chain that survives the four skip rules of §4.4.5;
|
|
17
|
+
* 4. `managerId` — the chat whose card shows it;
|
|
18
|
+
* 5. writes it `pending`, or `dismissed` when the policy is `off` (recorded all
|
|
19
|
+
* the same, so phase 2a-18 has real data), and tells the listeners
|
|
20
|
+
* (`onFallbackIncident`) — the BM-FALLBACK notice and the card.
|
|
21
|
+
*
|
|
22
|
+
* `<install home>/role-fallback-state.json` is user data like
|
|
23
|
+
* `role-extras.json` (F9): mode 0600, temp file then rename, symlink refused,
|
|
24
|
+
* never touched by an update, `--prune` or uninstall. At most 200 incidents,
|
|
25
|
+
* the oldest finished ones dropped first; every read-modify-write runs under
|
|
26
|
+
* one in-process mutex. A file that does not parse is never overwritten:
|
|
27
|
+
* nothing is recorded, with one log line each time, until it is fixed or deleted.
|
|
28
|
+
*
|
|
29
|
+
* Never throws into an agent turn: every failure costs one `[paseo-bm]` line.
|
|
30
|
+
*/
|
|
31
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
32
|
+
import { randomBytes } from "node:crypto";
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { roleOfProvider } from "./agent-role";
|
|
37
|
+
import { redactText, sliceLastTurn, type CollectorPaseo } from "./collector";
|
|
38
|
+
import { classifyTurn, quietReply } from "./fallback-detect";
|
|
39
|
+
import { chainOf, readRoleFallback, type FallbackChain } from "./fallback-settings";
|
|
40
|
+
import { TRACES_DIR_NAME } from "./install-home";
|
|
41
|
+
import { PARENT_AGENT_LABEL } from "./manager";
|
|
42
|
+
import { providerId } from "./provider-id";
|
|
43
|
+
import { asRecord, availableProviders, nonEmpty, reasonOf } from "./role-choices";
|
|
44
|
+
import { installHomeOf } from "./role-extras";
|
|
45
|
+
import { TIMED_OUT, withTimeout } from "./role-mode";
|
|
46
|
+
import { writeStoreFileAtomically } from "./trace-store";
|
|
47
|
+
import { fallbackAlias, positionOfAlias } from "../shared/fallback";
|
|
48
|
+
import { fallbackIncidentSchema, type BmRole, type FallbackIncident } from "../shared/contracts";
|
|
49
|
+
import type { FallbackClass } from "../shared/fallback-patterns";
|
|
50
|
+
|
|
51
|
+
export const ROLE_FALLBACK_STATE_FILE = "role-fallback-state.json";
|
|
52
|
+
|
|
53
|
+
/** Incidents kept; the oldest finished ones go first. */
|
|
54
|
+
export const MAX_INCIDENTS = 200;
|
|
55
|
+
|
|
56
|
+
/** Longest verbatim failure message kept. */
|
|
57
|
+
export const MAX_INCIDENT_MESSAGE_CHARS = 500;
|
|
58
|
+
|
|
59
|
+
/** Budget of the one `listUsage` call of an incident (§4.4.5 step 2). */
|
|
60
|
+
export const USAGE_TIMEOUT_MS = 5000;
|
|
61
|
+
|
|
62
|
+
/** Base providers whose usage Paseo can read (proposal S6) and whose L1 is worth a `listUsage`. */
|
|
63
|
+
export const USAGE_PROVIDERS: readonly string[] = ["claude", "codex"];
|
|
64
|
+
|
|
65
|
+
/** Model families a per-model window names (`seven_day_opus`), and that §4.4.5 compares. */
|
|
66
|
+
const MODEL_FAMILY = /(opus|sonnet|haiku|gpt)/i;
|
|
67
|
+
|
|
68
|
+
/** Statuses of an incident still being decided or acted on; dedupe also counts `switched`. */
|
|
69
|
+
const OPEN_STATUSES: ReadonlySet<FallbackIncident["status"]> = new Set(["pending", "waiting"]);
|
|
70
|
+
const DEDUPE_STATUSES: ReadonlySet<FallbackIncident["status"]> = new Set(["pending", "waiting", "switched"]);
|
|
71
|
+
|
|
72
|
+
const stateFileSchema = z.object({ version: z.literal(1), incidents: z.array(fallbackIncidentSchema) });
|
|
73
|
+
|
|
74
|
+
const defaultLog = (message: string): void => console.warn(message);
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// The file.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/** The incidents in `home`; `error` when the file exists but cannot be used. Never throws. */
|
|
81
|
+
export function readIncidents(home: string, log: (message: string) => void = defaultLog): { incidents: FallbackIncident[]; error: string | null } {
|
|
82
|
+
const path = join(home, ROLE_FALLBACK_STATE_FILE);
|
|
83
|
+
const invalid = (reason: string) => {
|
|
84
|
+
log(`[paseo-bm] ${path} is not usable (${reason}); no fallback incident is recorded until it is fixed or deleted.`);
|
|
85
|
+
return { incidents: [], error: reason };
|
|
86
|
+
};
|
|
87
|
+
let text: string;
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(path, "utf8");
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { incidents: [], error: null };
|
|
92
|
+
return invalid(reasonOf(error));
|
|
93
|
+
}
|
|
94
|
+
let parsed: unknown;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(text);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return invalid(`not JSON: ${reasonOf(error)}`);
|
|
99
|
+
}
|
|
100
|
+
const result = stateFileSchema.safeParse(parsed);
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
const issue = result.error.issues[0];
|
|
103
|
+
return invalid(issue === undefined ? "unexpected content" : `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
104
|
+
}
|
|
105
|
+
return { incidents: result.data.incidents, error: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Agent id → the agent that replaced it, for every `switched` incident with a
|
|
110
|
+
* replacement: with the `bm.replacedBy` label, how the one-Worker rule and the
|
|
111
|
+
* agent tree know a replaced agent even when labelling it failed (§4.4.8).
|
|
112
|
+
*/
|
|
113
|
+
export function replacementsOf(incidents: readonly FallbackIncident[]): Map<string, string> {
|
|
114
|
+
const out = new Map<string, string>();
|
|
115
|
+
for (const incident of incidents) {
|
|
116
|
+
if (incident.status === "switched" && incident.replacementId !== null) out.set(incident.agentId, incident.replacementId);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `replacementsOf` the incidents in the install home of `paseo`, within the
|
|
123
|
+
* lookup budget; empty when either cannot be read. Never throws.
|
|
124
|
+
*/
|
|
125
|
+
export async function replacementsFor(paseo: unknown, deps: { homedir?: () => string; home?: string | null } = {}): Promise<Map<string, string>> {
|
|
126
|
+
try {
|
|
127
|
+
const found = deps.home !== undefined ? deps.home : await withTimeout(installHomeOf(paseo, deps.homedir === undefined ? {} : { homedir: deps.homedir }));
|
|
128
|
+
if (found === null || found === TIMED_OUT) return new Map();
|
|
129
|
+
return replacementsOf(readIncidents(found, () => {}).incidents);
|
|
130
|
+
} catch {
|
|
131
|
+
return new Map();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** At most `MAX_INCIDENTS`: the oldest finished incidents go first, then the oldest of all. */
|
|
136
|
+
export function capIncidents(incidents: readonly FallbackIncident[]): FallbackIncident[] {
|
|
137
|
+
const kept = [...incidents];
|
|
138
|
+
while (kept.length > MAX_INCIDENTS) {
|
|
139
|
+
const finished = kept.findIndex((incident) => !OPEN_STATUSES.has(incident.status));
|
|
140
|
+
kept.splice(finished === -1 ? 0 : finished, 1);
|
|
141
|
+
}
|
|
142
|
+
return kept;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
146
|
+
|
|
147
|
+
/** Runs `work` after every update queued before it; a failure does not block the next one. */
|
|
148
|
+
function serialised<T>(work: () => Promise<T> | T): Promise<T> {
|
|
149
|
+
const run = queue.then(work, work);
|
|
150
|
+
queue = run.catch(() => undefined);
|
|
151
|
+
return run;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read-modify-write of the incidents under the mutex. `change` returns the new
|
|
156
|
+
* list, or `null` to write nothing. Returns what was written, or `null` when
|
|
157
|
+
* nothing was (no change, or an unusable file). Throws when the write fails.
|
|
158
|
+
*/
|
|
159
|
+
export function updateIncidents(
|
|
160
|
+
home: string,
|
|
161
|
+
change: (incidents: FallbackIncident[]) => FallbackIncident[] | null,
|
|
162
|
+
log: (message: string) => void = defaultLog,
|
|
163
|
+
): Promise<FallbackIncident[] | null> {
|
|
164
|
+
return serialised(() => {
|
|
165
|
+
const read = readIncidents(home, log);
|
|
166
|
+
if (read.error !== null) return null;
|
|
167
|
+
const next = change(read.incidents);
|
|
168
|
+
if (next === null) return null;
|
|
169
|
+
const incidents = capIncidents(next);
|
|
170
|
+
writeStoreFileAtomically(
|
|
171
|
+
{ tracesDir: join(home, TRACES_DIR_NAME) },
|
|
172
|
+
join(home, ROLE_FALLBACK_STATE_FILE),
|
|
173
|
+
`${JSON.stringify({ version: 1, incidents }, null, 2)}\n`,
|
|
174
|
+
);
|
|
175
|
+
return incidents;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Usage (§4.4.5 step 2).
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
export interface UsageFacts {
|
|
184
|
+
resetsAt: string | null;
|
|
185
|
+
perModelWindow: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const NO_USAGE: UsageFacts = { resetsAt: null, perModelWindow: false };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The reset time and window kind of `baseProvider`'s exhausted windows, from
|
|
192
|
+
* ONE `providers.listUsage()` raced against `USAGE_TIMEOUT_MS`. A window is
|
|
193
|
+
* exhausted at `usedPct >= 100` or `remainingPct <= 0`. Failure, timeout or no
|
|
194
|
+
* exhausted window → `{ resetsAt: null, perModelWindow: false }`. Never throws.
|
|
195
|
+
*/
|
|
196
|
+
export async function usageOf(paseo: unknown, baseProvider: string, log: (message: string) => void = defaultLog): Promise<UsageFacts> {
|
|
197
|
+
const providers = (paseo as { providers?: { listUsage?: unknown } } | null | undefined)?.providers;
|
|
198
|
+
const listUsage = providers?.listUsage;
|
|
199
|
+
if (typeof listUsage !== "function") return NO_USAGE;
|
|
200
|
+
try {
|
|
201
|
+
const result = await withTimeout(listUsage.call(providers) as Promise<{ providers?: unknown } | null | undefined>, USAGE_TIMEOUT_MS);
|
|
202
|
+
if (result === TIMED_OUT) {
|
|
203
|
+
log(`[paseo-bm] reading the usage of ${baseProvider} took longer than ${USAGE_TIMEOUT_MS} ms; the fallback card shows no reset time.`);
|
|
204
|
+
return NO_USAGE;
|
|
205
|
+
}
|
|
206
|
+
const entry = (Array.isArray(result?.providers) ? (result.providers as unknown[]) : [])
|
|
207
|
+
.map(asRecord)
|
|
208
|
+
.find((candidate) => candidate?.["providerId"] === baseProvider);
|
|
209
|
+
const windows = Array.isArray(entry?.["windows"]) ? (entry["windows"] as unknown[]).map(asRecord) : [];
|
|
210
|
+
const exhausted = windows.filter((window): window is Record<string, unknown> => {
|
|
211
|
+
if (window === null) return false;
|
|
212
|
+
const used = window["usedPct"];
|
|
213
|
+
const remaining = window["remainingPct"];
|
|
214
|
+
return (typeof used === "number" && used >= 100) || (typeof remaining === "number" && remaining <= 0);
|
|
215
|
+
});
|
|
216
|
+
if (exhausted.length === 0) return NO_USAGE;
|
|
217
|
+
let latest: { iso: string; at: number } | null = null;
|
|
218
|
+
for (const window of exhausted) {
|
|
219
|
+
const iso = nonEmpty(window["resetsAt"]);
|
|
220
|
+
const at = iso === null ? Number.NaN : Date.parse(iso);
|
|
221
|
+
if (iso !== null && !Number.isNaN(at) && (latest === null || at > latest.at)) latest = { iso, at };
|
|
222
|
+
}
|
|
223
|
+
const perModelWindow = exhausted.every((window) => MODEL_FAMILY.test(String(window["id"] ?? "")));
|
|
224
|
+
return { resetsAt: latest?.iso ?? null, perModelWindow };
|
|
225
|
+
} catch (error) {
|
|
226
|
+
log(`[paseo-bm] could not read the usage of ${baseProvider} (${reasonOf(error)}); the fallback card shows no reset time.`);
|
|
227
|
+
return NO_USAGE;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Candidate (§4.4.5 step 3).
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/** The model family of a model id (`claude-opus-5` → `opus`), or null. */
|
|
236
|
+
export function modelFamilyOf(model: string | null): string | null {
|
|
237
|
+
return model === null ? null : (MODEL_FAMILY.exec(model)?.[1]?.toLowerCase() ?? null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface CandidateInput {
|
|
241
|
+
role: BmRole;
|
|
242
|
+
chain: FallbackChain;
|
|
243
|
+
/** Position of the failed agent's alias: 0 for the main alias, 1…3 for a fallback. */
|
|
244
|
+
failedPosition: number;
|
|
245
|
+
failedBaseProvider: string | null;
|
|
246
|
+
failedModel: string | null;
|
|
247
|
+
cls: FallbackClass;
|
|
248
|
+
perModelWindow: boolean;
|
|
249
|
+
/** Aliases that already failed in the same scope (request, or workspace for a Manager). */
|
|
250
|
+
failedAliases: ReadonlySet<string>;
|
|
251
|
+
/** Available base providers; `null` when Paseo cannot say (the rule is then not applied). */
|
|
252
|
+
available: ReadonlySet<string> | null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The first entry after the failed agent's position that survives the four
|
|
257
|
+
* skip rules, or null when the chain is exhausted:
|
|
258
|
+
* 1. its alias already failed in the same scope;
|
|
259
|
+
* 2. its base provider is not available;
|
|
260
|
+
* 3. it shares the failed agent's base provider and the class is L2 or L4, or
|
|
261
|
+
* L1 without `perModelWindow` (a plan or login failure hits the whole provider);
|
|
262
|
+
* 4. L1 with `perModelWindow`, same base provider and the same model family.
|
|
263
|
+
*/
|
|
264
|
+
export function candidateOf(input: CandidateInput): FallbackIncident["candidate"] {
|
|
265
|
+
for (const [index, entry] of input.chain.entries.entries()) {
|
|
266
|
+
const position = index + 1;
|
|
267
|
+
if (position <= input.failedPosition) continue;
|
|
268
|
+
const alias = fallbackAlias(input.role, position);
|
|
269
|
+
if (input.failedAliases.has(alias)) continue;
|
|
270
|
+
if (input.available !== null && !input.available.has(entry.baseProvider)) continue;
|
|
271
|
+
const sameProvider = input.failedBaseProvider !== null && entry.baseProvider === input.failedBaseProvider;
|
|
272
|
+
if (sameProvider && (input.cls === "L2" || input.cls === "L4" || (input.cls === "L1" && !input.perModelWindow))) continue;
|
|
273
|
+
if (sameProvider && input.cls === "L1" && input.perModelWindow) {
|
|
274
|
+
const family = modelFamilyOf(input.failedModel);
|
|
275
|
+
if (family !== null && family === modelFamilyOf(entry.model)) continue;
|
|
276
|
+
}
|
|
277
|
+
return { position, alias, baseProvider: entry.baseProvider, model: entry.model, thinkingOptionId: entry.thinkingOptionId, modeId: entry.modeId };
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Recording one incident.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** The part of the SDK a snapshot read uses; `PaseoApi` is structurally assignable. */
|
|
287
|
+
interface SnapshotPaseo {
|
|
288
|
+
agents: { ref(agentId: string): { refresh(): Promise<{ agent?: unknown } | null> } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Gets each written incident, the Paseo handle of the hook that wrote it, the role's policy then, and the install home. */
|
|
292
|
+
export type IncidentListener = (
|
|
293
|
+
incident: FallbackIncident,
|
|
294
|
+
paseo: unknown,
|
|
295
|
+
context: { policy: FallbackChain["policy"]; home: string },
|
|
296
|
+
) => void | Promise<void>;
|
|
297
|
+
const listeners = new Set<IncidentListener>();
|
|
298
|
+
|
|
299
|
+
/** Subscribes to every incident written (the BM-FALLBACK notice and the card); returns the remover. */
|
|
300
|
+
export function onFallbackIncident(listener: IncidentListener): () => void {
|
|
301
|
+
listeners.add(listener);
|
|
302
|
+
return () => {
|
|
303
|
+
listeners.delete(listener);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface RecordDeps {
|
|
308
|
+
paseo: unknown;
|
|
309
|
+
home: string;
|
|
310
|
+
now?: () => Date;
|
|
311
|
+
log?: (message: string) => void;
|
|
312
|
+
/** 12 hex characters; tests pass a fixed one. */
|
|
313
|
+
randomHex?: () => string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** An agent snapshot (`refresh()`), raced against the lookup budget; null when unreadable. */
|
|
317
|
+
async function snapshotOf(paseo: unknown, agentId: string): Promise<Record<string, unknown> | null> {
|
|
318
|
+
try {
|
|
319
|
+
const ref = (paseo as SnapshotPaseo).agents.ref(agentId);
|
|
320
|
+
const result = await withTimeout(ref.refresh());
|
|
321
|
+
return result === TIMED_OUT ? null : asRecord(result?.agent);
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const labelsOf = (snapshot: Record<string, unknown> | null): Record<string, unknown> => asRecord(snapshot?.["labels"]) ?? {};
|
|
328
|
+
|
|
329
|
+
/** The model the agent runs: `runtimeInfo.model` first (AGENTS.md), else the configured one. */
|
|
330
|
+
function modelOf(snapshot: Record<string, unknown> | null): string | null {
|
|
331
|
+
return nonEmpty(asRecord(snapshot?.["runtimeInfo"])?.["model"]) ?? nonEmpty(snapshot?.["model"]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The `extends` of every alias, from one `config.get()` under the lookup budget; `{}` when unreadable. */
|
|
335
|
+
async function aliasBases(paseo: unknown): Promise<Record<string, string>> {
|
|
336
|
+
const config = (paseo as { config?: { get?: unknown } } | null | undefined)?.config;
|
|
337
|
+
if (typeof config?.get !== "function") return {};
|
|
338
|
+
try {
|
|
339
|
+
const result = await withTimeout(config.get.call(config) as Promise<{ config?: unknown } | null | undefined>);
|
|
340
|
+
if (result === TIMED_OUT) return {};
|
|
341
|
+
const providers = asRecord(asRecord(result?.config)?.["providers"]) ?? {};
|
|
342
|
+
const out: Record<string, string> = {};
|
|
343
|
+
for (const [id, entry] of Object.entries(providers)) {
|
|
344
|
+
const base = nonEmpty(asRecord(entry)?.["extends"]);
|
|
345
|
+
if (base !== null) out[id] = base;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
} catch {
|
|
349
|
+
return {};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Builds and writes the incident of one classified turn (§4.4.5 steps 1–5).
|
|
355
|
+
* Returns it, or null when nothing was written: the agent already has an open
|
|
356
|
+
* incident, the turn has no workspace, or the file cannot be used. Never throws.
|
|
357
|
+
*/
|
|
358
|
+
export async function recordIncident(
|
|
359
|
+
event: { agent: { id: string; provider: string; workspaceId?: string | null } },
|
|
360
|
+
signal: { class: FallbackClass; signal: "failed" | "completed"; message: string },
|
|
361
|
+
deps: RecordDeps,
|
|
362
|
+
): Promise<FallbackIncident | null> {
|
|
363
|
+
const log = deps.log ?? defaultLog;
|
|
364
|
+
const now = deps.now ?? (() => new Date());
|
|
365
|
+
try {
|
|
366
|
+
const agentId = event.agent.id;
|
|
367
|
+
const agentProvider = event.agent.provider;
|
|
368
|
+
const role = roleOfProvider(agentProvider);
|
|
369
|
+
const workspaceId = event.agent.workspaceId ?? null;
|
|
370
|
+
if (role === null || workspaceId === null) return null;
|
|
371
|
+
const hasOpen = (incidents: readonly FallbackIncident[]) =>
|
|
372
|
+
incidents.some((incident) => incident.agentId === agentId && DEDUPE_STATUSES.has(incident.status));
|
|
373
|
+
|
|
374
|
+
const existing = readIncidents(deps.home, log);
|
|
375
|
+
if (existing.error !== null || hasOpen(existing.incidents)) return null;
|
|
376
|
+
|
|
377
|
+
const chain = chainOf(readRoleFallback(deps.home, log).file, role);
|
|
378
|
+
const snapshot = await snapshotOf(deps.paseo, agentId);
|
|
379
|
+
const labels = labelsOf(snapshot);
|
|
380
|
+
const requestId = role === "manager" ? null : nonEmpty(labels["bm.requestId"]);
|
|
381
|
+
const agentModel = modelOf(snapshot);
|
|
382
|
+
const alias = providerId(agentProvider) ?? agentProvider;
|
|
383
|
+
const bases = await aliasBases(deps.paseo);
|
|
384
|
+
const failedBaseProvider = bases[alias] ?? null;
|
|
385
|
+
|
|
386
|
+
const usage =
|
|
387
|
+
signal.class === "L1" && chain.policy !== "off" && failedBaseProvider !== null && USAGE_PROVIDERS.includes(failedBaseProvider)
|
|
388
|
+
? await usageOf(deps.paseo, failedBaseProvider, log)
|
|
389
|
+
: NO_USAGE;
|
|
390
|
+
|
|
391
|
+
const scope = (incident: FallbackIncident) =>
|
|
392
|
+
incident.role === role && (role === "manager" ? incident.workspaceId === workspaceId : requestId !== null && incident.requestId === requestId);
|
|
393
|
+
const failedAliases = new Set(existing.incidents.filter(scope).map((incident) => providerId(incident.agentProvider) ?? incident.agentProvider));
|
|
394
|
+
failedAliases.add(alias);
|
|
395
|
+
const candidate = candidateOf({
|
|
396
|
+
role,
|
|
397
|
+
chain,
|
|
398
|
+
failedPosition: positionOfAlias(alias) ?? 0,
|
|
399
|
+
failedBaseProvider,
|
|
400
|
+
failedModel: agentModel,
|
|
401
|
+
cls: signal.class,
|
|
402
|
+
perModelWindow: usage.perModelWindow,
|
|
403
|
+
failedAliases,
|
|
404
|
+
available: await availableProviders(deps.paseo),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const parentId = role === "manager" ? null : nonEmpty(labels[PARENT_AGENT_LABEL]);
|
|
408
|
+
let managerId: string | null;
|
|
409
|
+
if (role === "manager") managerId = agentId;
|
|
410
|
+
else if (role === "worker") managerId = parentId;
|
|
411
|
+
else managerId = parentId === null ? null : nonEmpty(labelsOf(await snapshotOf(deps.paseo, parentId))[PARENT_AGENT_LABEL]);
|
|
412
|
+
|
|
413
|
+
const detectedAt = now().toISOString();
|
|
414
|
+
const off = chain.policy === "off";
|
|
415
|
+
const incident: FallbackIncident = {
|
|
416
|
+
id: `fb-${(deps.randomHex ?? (() => randomBytes(6).toString("hex")))()}`,
|
|
417
|
+
role,
|
|
418
|
+
workspaceId,
|
|
419
|
+
requestId,
|
|
420
|
+
agentId,
|
|
421
|
+
agentProvider,
|
|
422
|
+
agentModel,
|
|
423
|
+
parentId,
|
|
424
|
+
managerId,
|
|
425
|
+
class: signal.class,
|
|
426
|
+
signal: signal.signal,
|
|
427
|
+
// Verbatim but masked like every trace record (REQ-048b), then cut.
|
|
428
|
+
message: redactText(signal.message).slice(0, MAX_INCIDENT_MESSAGE_CHARS),
|
|
429
|
+
perModelWindow: usage.perModelWindow,
|
|
430
|
+
resetsAt: usage.resetsAt,
|
|
431
|
+
candidate,
|
|
432
|
+
status: off ? "dismissed" : "pending",
|
|
433
|
+
detectedAt,
|
|
434
|
+
decidedAt: off ? detectedAt : null,
|
|
435
|
+
waitUntil: null,
|
|
436
|
+
replacementId: null,
|
|
437
|
+
error: null,
|
|
438
|
+
};
|
|
439
|
+
// The usage call ran outside the lock: check again that no incident of
|
|
440
|
+
// this agent was written meanwhile, and append in the same step.
|
|
441
|
+
const written = await updateIncidents(deps.home, (incidents) => (hasOpen(incidents) ? null : [...incidents, incident]), log);
|
|
442
|
+
if (written === null) return null;
|
|
443
|
+
for (const listener of listeners) {
|
|
444
|
+
try {
|
|
445
|
+
await listener(incident, deps.paseo, { policy: chain.policy, home: deps.home });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
log(`[paseo-bm] a fallback incident listener failed: ${reasonOf(error)}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return incident;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
log(`[paseo-bm] recording a fallback incident for ${event?.agent?.id ?? "(unknown)"} failed: ${reasonOf(error)}`);
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Registration.
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
export type FallbackHost = Partial<Pick<PluginServerContext, "on">>;
|
|
462
|
+
|
|
463
|
+
export interface RegisterFallbackOptions {
|
|
464
|
+
log?: (message: string) => void;
|
|
465
|
+
now?: () => Date;
|
|
466
|
+
/** The install home; tests pass one, the plugin looks it up (and keeps it once found). */
|
|
467
|
+
home?: () => Promise<string | null>;
|
|
468
|
+
/** Hears the SDK handle of every paseo-bm turn end (the wait timers are set again from it, §4.4.9). */
|
|
469
|
+
onPaseo?: (paseo: unknown) => void;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Adds the `agent.turn_ended` handler that classifies the turn and records an
|
|
474
|
+
* incident. An ordinary turn costs nothing: the install home and the user's
|
|
475
|
+
* patterns are only read for a paseo-bm turn that failed, or that completed
|
|
476
|
+
* with a short reply and nothing else (N2), and `classifyTurn` makes no daemon
|
|
477
|
+
* call before its text classifies. Returns the remover; on a host without
|
|
478
|
+
* `on` it does nothing.
|
|
479
|
+
*/
|
|
480
|
+
export function registerFallbackDetection(host: FallbackHost, options: RegisterFallbackOptions = {}): () => void {
|
|
481
|
+
if (typeof host.on !== "function") return () => {};
|
|
482
|
+
const log = options.log ?? defaultLog;
|
|
483
|
+
let knownHome: string | null = null;
|
|
484
|
+
const homeOf = async (paseo: unknown): Promise<string | null> => {
|
|
485
|
+
if (options.home !== undefined) return options.home();
|
|
486
|
+
if (knownHome !== null) return knownHome;
|
|
487
|
+
const found = await withTimeout(installHomeOf(paseo));
|
|
488
|
+
knownHome = found === TIMED_OUT ? null : found;
|
|
489
|
+
return knownHome;
|
|
490
|
+
};
|
|
491
|
+
const remove = host.on("agent.turn_ended", async (event, context) => {
|
|
492
|
+
try {
|
|
493
|
+
const outcome = event?.outcome?.kind;
|
|
494
|
+
if (roleOfProvider(event?.agent?.provider) === null) return;
|
|
495
|
+
const handle = (context as { paseo?: unknown } | undefined)?.paseo;
|
|
496
|
+
if (handle !== undefined) options.onPaseo?.(handle);
|
|
497
|
+
if (outcome !== "failed" && outcome !== "completed") return;
|
|
498
|
+
const text =
|
|
499
|
+
outcome === "failed"
|
|
500
|
+
? nonEmpty((event.outcome as { error?: { message?: unknown } }).error?.message)
|
|
501
|
+
: quietReply(sliceLastTurn(Array.isArray(event.timeline) ? event.timeline : []));
|
|
502
|
+
if (text === null) return;
|
|
503
|
+
const paseo = (context as { paseo?: unknown } | undefined)?.paseo;
|
|
504
|
+
if (paseo === undefined) return;
|
|
505
|
+
const home = await homeOf(paseo);
|
|
506
|
+
if (home === null) return;
|
|
507
|
+
const patterns = readRoleFallback(home, log).file.patterns ?? null;
|
|
508
|
+
const signal = await classifyTurn(event, { paseo: paseo as CollectorPaseo, patterns, log });
|
|
509
|
+
if (signal === null) return;
|
|
510
|
+
await recordIncident(event, signal, { paseo, home, log, ...(options.now === undefined ? {} : { now: options.now }) });
|
|
511
|
+
} catch (error) {
|
|
512
|
+
log(`[paseo-bm] fallback detection failed: ${reasonOf(error)}`);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return () => {
|
|
516
|
+
if (typeof remove === "function") remove();
|
|
517
|
+
};
|
|
518
|
+
}
|