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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two `paseo` CLI commands the plugin runs (delta 20260918 §4.1).
|
|
3
|
+
*
|
|
4
|
+
* The plugin SDK cannot change an existing agent's mode or labels:
|
|
5
|
+
* `PaseoAgentHandle` has no setter for either, `DaemonClient.setAgentMode` is
|
|
6
|
+
* not handed to plugins, and `before("agent.session_open")` only edits `env`
|
|
7
|
+
* (N2, N3). Paseo CLI 0.8 can: `paseo agent mode <id> <mode>` and
|
|
8
|
+
* `paseo agent update <id> --label <k=v>`. The binary is found the way the
|
|
9
|
+
* Setup screen finds `br` (`findTool`), and run the way it runs one: `execFile`,
|
|
10
|
+
* never a shell, with a timeout.
|
|
11
|
+
*
|
|
12
|
+
* Every argument is checked before it reaches the command line. A value that
|
|
13
|
+
* starts with `-` would be read as a flag, so ids, modes and labels must start
|
|
14
|
+
* with a letter or digit and hold nothing but the characters Paseo uses.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { findTool } from "./setup-tools";
|
|
18
|
+
|
|
19
|
+
/** How long one CLI call may take before it counts as failed. */
|
|
20
|
+
export const CLI_TIMEOUT_MS = 5000;
|
|
21
|
+
|
|
22
|
+
/** An agent id as Paseo mints it: never a flag, never a path. */
|
|
23
|
+
const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
24
|
+
/** A mode id, a label key or a label value: never a flag, no `=` or spaces. */
|
|
25
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
26
|
+
|
|
27
|
+
export interface CliOutcome {
|
|
28
|
+
code: number;
|
|
29
|
+
output: string;
|
|
30
|
+
timedOut: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CliRunner = (file: string, args: string[], timeoutMs: number) => Promise<CliOutcome>;
|
|
34
|
+
|
|
35
|
+
export interface PaseoCliDeps {
|
|
36
|
+
/** Runs the binary. Tests pass a fake; nothing in a test may start the real `paseo`. */
|
|
37
|
+
run?: CliRunner;
|
|
38
|
+
/** Finds the `paseo` binary, or `null`. */
|
|
39
|
+
find?: () => string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type CliResult = { ok: true } | { ok: false; reason: string };
|
|
43
|
+
|
|
44
|
+
export function isSafeAgentId(id: string): boolean {
|
|
45
|
+
return AGENT_ID.test(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runExecFile(file: string, args: string[], timeoutMs: number): Promise<CliOutcome> {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
execFile(file, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
51
|
+
const failure = error as (Error & { code?: unknown; killed?: boolean }) | null;
|
|
52
|
+
const code = failure === null ? 0 : typeof failure.code === "number" ? failure.code : 1;
|
|
53
|
+
resolve({ code, output: `${stdout}${stderr}`, timedOut: failure?.killed === true });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The first non-empty line of the CLI's output, short enough for a notice. */
|
|
59
|
+
function firstLine(output: string): string {
|
|
60
|
+
const line = output.split("\n").map((part) => part.trim()).find((part) => part !== "") ?? "";
|
|
61
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runPaseo(args: string[], deps: PaseoCliDeps): Promise<CliResult> {
|
|
65
|
+
const binary = (deps.find ?? (() => findTool("paseo")))();
|
|
66
|
+
if (binary === null) return { ok: false, reason: "the `paseo` command was not found" };
|
|
67
|
+
let outcome: CliOutcome;
|
|
68
|
+
try {
|
|
69
|
+
outcome = await (deps.run ?? runExecFile)(binary, args, CLI_TIMEOUT_MS);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
72
|
+
}
|
|
73
|
+
if (outcome.timedOut) return { ok: false, reason: `\`paseo ${args.slice(0, 2).join(" ")}\` took longer than ${CLI_TIMEOUT_MS} ms` };
|
|
74
|
+
if (outcome.code !== 0) {
|
|
75
|
+
const detail = firstLine(outcome.output);
|
|
76
|
+
return { ok: false, reason: `\`paseo ${args.slice(0, 2).join(" ")}\` exited with ${outcome.code}${detail === "" ? "" : `: ${detail}`}` };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `paseo agent mode <id> <mode> --json`. */
|
|
82
|
+
export function setAgentMode(agentId: string, modeId: string, deps: PaseoCliDeps = {}): Promise<CliResult> {
|
|
83
|
+
if (!isSafeAgentId(agentId)) return Promise.resolve({ ok: false, reason: `refusing an unexpected agent id "${agentId}"` });
|
|
84
|
+
if (!TOKEN.test(modeId)) return Promise.resolve({ ok: false, reason: `refusing an unexpected mode "${modeId}"` });
|
|
85
|
+
return runPaseo(["agent", "mode", agentId, modeId, "--json"], deps);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `paseo agent update <id> --label <key>=<value> --json`. */
|
|
89
|
+
export function setAgentLabel(agentId: string, key: string, value: string, deps: PaseoCliDeps = {}): Promise<CliResult> {
|
|
90
|
+
if (!isSafeAgentId(agentId)) return Promise.resolve({ ok: false, reason: `refusing an unexpected agent id "${agentId}"` });
|
|
91
|
+
if (!TOKEN.test(key) || !TOKEN.test(value)) {
|
|
92
|
+
return Promise.resolve({ ok: false, reason: `refusing an unexpected label "${key}=${value}"` });
|
|
93
|
+
}
|
|
94
|
+
return runPaseo(["agent", "update", agentId, "--label", `${key}=${value}`, "--json"], deps);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `paseo agent update <id> --label <k>=<v> [--label <k>=<v> …] --json`: one
|
|
99
|
+
* command for several labels. Paseo adds or sets each label and removes none
|
|
100
|
+
* (`paseo agent update --help`, 0.8.0). Every key and value is checked first.
|
|
101
|
+
*/
|
|
102
|
+
export function setAgentLabels(agentId: string, labels: Readonly<Record<string, string>>, deps: PaseoCliDeps = {}): Promise<CliResult> {
|
|
103
|
+
if (!isSafeAgentId(agentId)) return Promise.resolve({ ok: false, reason: `refusing an unexpected agent id "${agentId}"` });
|
|
104
|
+
const entries = Object.entries(labels);
|
|
105
|
+
if (entries.length === 0) return Promise.resolve({ ok: false, reason: "no label to set" });
|
|
106
|
+
const args = ["agent", "update", agentId];
|
|
107
|
+
for (const [key, value] of entries) {
|
|
108
|
+
if (!TOKEN.test(key) || !TOKEN.test(value)) {
|
|
109
|
+
return Promise.resolve({ ok: false, reason: `refusing an unexpected label "${key}=${value}"` });
|
|
110
|
+
}
|
|
111
|
+
args.push("--label", `${key}=${value}`);
|
|
112
|
+
}
|
|
113
|
+
args.push("--json");
|
|
114
|
+
return runPaseo(args, deps);
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider id of a provider selection: `bm-worker` and `bm-worker/<model>` both
|
|
3
|
+
* name the `bm-worker` provider. Anything that is not a string yields `null`.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the `agent.create` role hook and the `agent.turn_ended` stop
|
|
6
|
+
* propagation so both recognise paseo-bm roles the same way.
|
|
7
|
+
*/
|
|
8
|
+
export function providerId(provider: unknown): string | null {
|
|
9
|
+
if (typeof provider !== "string") return null;
|
|
10
|
+
const slash = provider.indexOf("/");
|
|
11
|
+
return slash === -1 ? provider : provider.slice(0, slash);
|
|
12
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review budget, counted by the plugin (delta 20260917c §4.7, REQ-037 errata).
|
|
3
|
+
*
|
|
4
|
+
* The Worker used to count its own review calls and the Manager used to check
|
|
5
|
+
* the count. The 2026-09-17 acceptance run showed the trace store counting 4
|
|
6
|
+
* calls correctly while the Worker reported 5, so the number now comes from the
|
|
7
|
+
* one place that gets it right: `reviewCallsOf`, the same figure the Dashboard
|
|
8
|
+
* shows. The guardrail stays behavioural — this module only TELLS the Manager;
|
|
9
|
+
* the Manager decides and the user permits. Nothing here stops an agent.
|
|
10
|
+
*
|
|
11
|
+
* Two properties this module must never lose (review b2):
|
|
12
|
+
* - It only ever tells the Manager about an overrun a Worker's or Reviewer's
|
|
13
|
+
* turn end DISCOVERED in this process. A Manager turn end can only flush
|
|
14
|
+
* what is already pending. Without that, a restart would make the first
|
|
15
|
+
* Manager turn end announce every old finished request, and each notice
|
|
16
|
+
* starts a Manager turn whose end announces the next one.
|
|
17
|
+
* - One notice per request, even when two turn ends are handled at once: the
|
|
18
|
+
* request is marked before the first `await`, not after the send.
|
|
19
|
+
*
|
|
20
|
+
* Facts this relies on (checked, not guessed):
|
|
21
|
+
* - A Reviewer's inbound request is recorded when that Reviewer's turn ends, so
|
|
22
|
+
* a new call becomes visible at a Reviewer turn end. The Worker's last report
|
|
23
|
+
* usually WAKES the Manager, so a notice found at the Worker's turn end is
|
|
24
|
+
* deferred — which is why the MANAGER's own turn end is checked too, and is
|
|
25
|
+
* often the moment the notice can finally go out (review b1, B2).
|
|
26
|
+
* - `PaseoAgentHandle.send()` on a running agent REPLACES its current turn —
|
|
27
|
+
* `stop-propagation.ts` relies on exactly that to interrupt a Reviewer. A
|
|
28
|
+
* Manager in the middle of answering the user must not be cut off, so the
|
|
29
|
+
* notice is sent only when `refresh()` says the Manager is not `running`.
|
|
30
|
+
* - A Manager turn is attributed to the request whose `requestId: req-…` it
|
|
31
|
+
* contains (`managerRequestId`), so the notice carries that exact form and
|
|
32
|
+
* never opens a request of its own.
|
|
33
|
+
*
|
|
34
|
+
* Lifecycle belongs to the user (ADR-005): nothing here archives, deletes or
|
|
35
|
+
* cancels anything.
|
|
36
|
+
*/
|
|
37
|
+
import type { PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
|
38
|
+
import type { Tier } from "../shared/contracts";
|
|
39
|
+
import { agentFactsOf, reviewerReplacementsFor, type DashboardPaseo } from "./dashboard-rpc";
|
|
40
|
+
import { BUDGET_NOTICE_MARKER } from "./notices";
|
|
41
|
+
import { roleOfProvider } from "./agent-role";
|
|
42
|
+
import { readRecords, type TraceStoreLocation } from "./trace-store";
|
|
43
|
+
import { reconstructTraces, type ReconstructedTrace } from "./traces";
|
|
44
|
+
|
|
45
|
+
/** Total review calls per request, by tier (REQ-037; unchanged by this delta). */
|
|
46
|
+
export const REVIEW_BUDGET: Readonly<Record<Tier, number>> = { Small: 1, Medium: 4, Large: 6 };
|
|
47
|
+
|
|
48
|
+
/** First word of the notice; `roles/manager.md` tells the Manager what to do with it. */
|
|
49
|
+
export { BUDGET_NOTICE_MARKER } from "./notices";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The fixed notice the Manager receives, word for word from design delta §4.7.
|
|
53
|
+
* The Manager ASKS the user; it never cancels on the notice alone, because the
|
|
54
|
+
* user may have allowed the extra calls in the Worker's own chat (REQ-026e,
|
|
55
|
+
* owner decision Q21). `roles/manager.md` pins the same text.
|
|
56
|
+
*/
|
|
57
|
+
export function budgetNotice(over: BudgetOverrun): string {
|
|
58
|
+
return (
|
|
59
|
+
`${BUDGET_NOTICE_MARKER} requestId: ${over.requestId}\n` +
|
|
60
|
+
`The Worker has used ${over.calls} review calls; the ${over.tier} budget is ${over.budget}. ` +
|
|
61
|
+
"Ask the user whether to continue or to cancel the Worker's run, and wait for the answer. " +
|
|
62
|
+
"Do not cancel on your own: the user may already have allowed the extra calls in the Worker's chat."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BudgetOverrun {
|
|
67
|
+
requestId: string;
|
|
68
|
+
tier: Tier;
|
|
69
|
+
calls: number;
|
|
70
|
+
budget: number;
|
|
71
|
+
managerAgentId: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The overrun of one reconstructed request, or `null` when it is within
|
|
76
|
+
* budget, or when its tier, its call count, its request id or its Manager is
|
|
77
|
+
* not known — an unknown number never produces a notice.
|
|
78
|
+
*/
|
|
79
|
+
export function overrunOf(trace: ReconstructedTrace): BudgetOverrun | null {
|
|
80
|
+
if (trace.requestId === null || trace.tier === null || trace.reviewCalls === null) return null;
|
|
81
|
+
if (trace.managerAgentId === null) return null;
|
|
82
|
+
const budget = REVIEW_BUDGET[trace.tier];
|
|
83
|
+
if (budget === undefined || trace.reviewCalls <= budget) return null;
|
|
84
|
+
return {
|
|
85
|
+
requestId: trace.requestId,
|
|
86
|
+
tier: trace.tier,
|
|
87
|
+
calls: trace.reviewCalls,
|
|
88
|
+
budget,
|
|
89
|
+
managerAgentId: trace.managerAgentId,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
|
|
94
|
+
|
|
95
|
+
/** What the check needs from Paseo: the Dashboard's agent listing, plus refresh and send. */
|
|
96
|
+
export interface BudgetPaseo extends Omit<DashboardPaseo, "agents"> {
|
|
97
|
+
agents: Omit<DashboardPaseo["agents"], "ref"> & {
|
|
98
|
+
ref?(agentId: string): {
|
|
99
|
+
timeline: { refetch(options: Record<string, unknown>): Promise<unknown> };
|
|
100
|
+
refresh?(): Promise<{ agent?: { status?: string | null; archivedAt?: string | null } | null } | null>;
|
|
101
|
+
send?(text: string): Promise<void>;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface BudgetDeps {
|
|
107
|
+
location: TraceStoreLocation;
|
|
108
|
+
paseo: BudgetPaseo;
|
|
109
|
+
/** `workspaceId::requestId` of every request already told, or being told now. */
|
|
110
|
+
told: Set<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Overruns found at a Worker's or Reviewer's turn end that could not be sent
|
|
113
|
+
* yet (the Manager was running). Only these may go out at a Manager turn end,
|
|
114
|
+
* so a restart never announces old finished requests.
|
|
115
|
+
*/
|
|
116
|
+
pending?: Map<string, BudgetOverrun>;
|
|
117
|
+
log?: (message: string) => void;
|
|
118
|
+
/**
|
|
119
|
+
* The install home, whose `role-fallback-state.json` names the Reviewers
|
|
120
|
+
* that replaced a stopped one (delta 20260921 §4.5.1); looked up otherwise.
|
|
121
|
+
*/
|
|
122
|
+
home?: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type BudgetOutcome = "ignored" | "within" | "already-told" | "deferred" | "sent";
|
|
126
|
+
|
|
127
|
+
const NO_PENDING = new Map<string, BudgetOverrun>();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks the requests of the agent whose turn just ended, and tells their
|
|
131
|
+
* Manager once each when a request is over budget. Call it only after the
|
|
132
|
+
* collector has written that turn's record. One notice per call, so a Manager
|
|
133
|
+
* is never sent two messages back to back; the next turn end carries the next.
|
|
134
|
+
* Never throws.
|
|
135
|
+
*/
|
|
136
|
+
export async function checkReviewBudget(event: TurnEndedEvent, deps: BudgetDeps): Promise<BudgetOutcome> {
|
|
137
|
+
const log = deps.log ?? ((message: string) => console.warn(message));
|
|
138
|
+
const pending = deps.pending ?? NO_PENDING;
|
|
139
|
+
let claimed: string | null = null;
|
|
140
|
+
try {
|
|
141
|
+
const agent = event?.agent;
|
|
142
|
+
// Every paseo-bm role counts, on its main alias or a fallback one (delta 20260921 §4.4.1).
|
|
143
|
+
const role = roleOfProvider(agent?.provider);
|
|
144
|
+
const workspaceId = agent?.workspaceId ?? null;
|
|
145
|
+
if (role === null || workspaceId === null) return "ignored";
|
|
146
|
+
const keyOf = (found: BudgetOverrun): string => `${workspaceId}::${found.requestId}`;
|
|
147
|
+
|
|
148
|
+
let over: BudgetOverrun | undefined;
|
|
149
|
+
if (role === "manager") {
|
|
150
|
+
// A Manager turn end only FLUSHES what a Worker or Reviewer turn end
|
|
151
|
+
// already found — usually the notice deferred moments ago because this
|
|
152
|
+
// Manager was still answering the Worker's last report.
|
|
153
|
+
over = [...pending.values()].find((found) => keyOf(found) === `${workspaceId}::${found.requestId}` && found.managerAgentId === agent.id);
|
|
154
|
+
if (over === undefined) return "within";
|
|
155
|
+
} else {
|
|
156
|
+
const records = readRecords(deps.location, workspaceId).records;
|
|
157
|
+
// A replacement Reviewer's first message is the stopped Reviewer's
|
|
158
|
+
// review call sent again, not a new one (delta 20260921 §4.5.1).
|
|
159
|
+
const [facts, replacementIds] = await Promise.all([
|
|
160
|
+
agentFactsOf(deps.paseo, workspaceId),
|
|
161
|
+
reviewerReplacementsFor(deps.paseo, { home: deps.home }),
|
|
162
|
+
]);
|
|
163
|
+
const trace = reconstructTraces({ records, agents: [...facts.values()], replacementIds }).find(
|
|
164
|
+
(candidate) => candidate.workerIds.includes(agent.id) || candidate.reviewerIds.includes(agent.id),
|
|
165
|
+
);
|
|
166
|
+
over = trace === undefined ? undefined : (overrunOf(trace) ?? undefined);
|
|
167
|
+
if (over === undefined) return "within";
|
|
168
|
+
if (deps.told.has(keyOf(over))) return "already-told";
|
|
169
|
+
pending.set(keyOf(over), over);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const key = keyOf(over);
|
|
173
|
+
if (deps.told.has(key)) return "already-told";
|
|
174
|
+
// Claimed BEFORE any await: two turn ends handled at once must not both send.
|
|
175
|
+
deps.told.add(key);
|
|
176
|
+
claimed = key;
|
|
177
|
+
|
|
178
|
+
const handle = deps.paseo.agents.ref?.(over.managerAgentId);
|
|
179
|
+
if (typeof handle?.refresh !== "function" || typeof handle.send !== "function") {
|
|
180
|
+
log("[paseo-bm] cannot reach the Manager on this host; the review budget notice was not sent.");
|
|
181
|
+
return "deferred";
|
|
182
|
+
}
|
|
183
|
+
const snapshot = await handle.refresh();
|
|
184
|
+
const status = snapshot?.agent?.status ?? null;
|
|
185
|
+
if (status === "running") return "deferred";
|
|
186
|
+
// Lifecycle belongs to the user (ADR-005): `send()` un-archives an archived
|
|
187
|
+
// agent and starts a turn on it, so an archived or closed Manager is left
|
|
188
|
+
// alone and the notice is dropped rather than deferred forever.
|
|
189
|
+
if (snapshot?.agent?.archivedAt != null || status === "closed") {
|
|
190
|
+
log(`[paseo-bm] the Manager of ${over.requestId} is archived or closed; the review budget notice was not sent.`);
|
|
191
|
+
pending.delete(key);
|
|
192
|
+
claimed = null;
|
|
193
|
+
return "ignored";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await handle.send(budgetNotice(over));
|
|
197
|
+
pending.delete(key);
|
|
198
|
+
claimed = null;
|
|
199
|
+
return "sent";
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// This runs inside a real agent's turn end: a failure costs one notice, never the turn.
|
|
202
|
+
log(`[paseo-bm] review budget check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
203
|
+
return "deferred";
|
|
204
|
+
} finally {
|
|
205
|
+
// Not sent after all: let a later turn end try again.
|
|
206
|
+
if (claimed !== null) deps.told.delete(claimed);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Regenerated from plugin/roles/reviewer.md by scripts/generate-role-instructions.mjs,
|
|
3
|
+
// which the build runs before packing. Edit the markdown, then run `npm run build`.
|
|
4
|
+
|
|
5
|
+
/** Name of the embedded instructions, as reported by `roles.describe`. */
|
|
6
|
+
export const REVIEWER_INSTRUCTIONS_NAME = "roles/reviewer.md";
|
|
7
|
+
|
|
8
|
+
/** Exact text of `roles/reviewer.md`, baked in at build time. */
|
|
9
|
+
export const REVIEWER_INSTRUCTIONS = "# Beads Reviewer — role instructions\n\nYou are **Beads Reviewer**, an agent inside Paseo. A Beads Worker created you to\nreview **ONE batch** of its changes and return one short structured result. It\nmay send you one re-review of the same batch.\n\n**REVIEW AGAINST THE REQUEST, NOT AGAINST PERFECTION.** The only question: does\nthis batch do what the user asked, correctly and safely?\n\n## RULES\n\nFour limits, about CLASSES of action rather than lists of commands.\n\n1. **YOU CHANGE NOTHING.** You read, and you run checks.\n Never edit files, beads or git; never create, message, stop, archive or\n delete an agent. Having a tool is not permission to use it. A scratch\n directory you just made yourself is not a change — see What you may run. If\n a check would need anything these four limits hold back, skip it and name it\n under `notChecked`.\n2. **NOTHING LEAVES THIS MACHINE**: no network, no installing, no downloading.\n3. **NEVER READ SECRETS** (`.env`, credentials, tokens, keys). If the batch adds\n one, report it as blocking without repeating its value.\n4. **ONE BATCH, ONE RESULT.** Review only the scope you were given, and never\n ask for another review round.\n\n## What you may run\n\nThe repository's existing test commands. Lint or typecheck commands that write\nno files. Throwaway scripts inside a directory you just created with `mktemp -d`\n(delete it before you answer). Reading anything in the repository, `br show\n<id>`, `br list --json`, `br dep tree`, `br ready`, `br lint`, `git diff`,\n`git status`, `git log`.\n\nRun `git status --porcelain` before and after running commands; if it changed,\nsay so in `notChecked` and do NOT clean up.\n\n## What you review\n\nThe Worker's message gives you everything that varies: the `requestId`, the\n`batchId`, the stage, the scope, **the criteria for that stage**, and for an\nimplementation batch the checks it ran. **If the requestId, batchId, stage or\nscope is missing or unclear, do not guess**: return `changes-required` with one\nblocking finding naming what is missing.\n\nRead only what the scope names:\n\n- **documents:** the documents, and the cited sources you need to check them.\n- **beads:** the beads (`br show <id>`) and the documents they cite.\n- **plan** (Medium): the changed document sections and the beads, together.\n- **implementation:** all beads of the request, the whole change, and the\n checks the Worker reports; run the repository's tests yourself when you can.\n A simple check (for example compiling the edited file) is enough for a small\n change — do not ask for more tests. Only a missing check for behaviour that\n needs one is blocking. When the outcome is not code, check the evidence the\n bead named: a conclusion against the sources it cites, a configuration by\n reading the file back, a screen or an endpoint against the response the\n Worker captured.\n- **Re-review** of the same `batchId`: check ONLY that the previous blocking\n findings are fixed and the fixes broke nothing. No new non-blocking points.\n\nLoad the criteria the message names, **as criteria only**: when a skill says to\nedit something, report it as a finding instead. If the message names none, use\nthis map and say in `notChecked` that the brief named no criteria — documents →\n`feature-workflow` `checklists/prd-ready.md` and `checklists/design-ready.md`;\nplan → `reviewing-plan` in its review-only mode and\n`feature-workflow/checklists/plan-ready-for-beads.md`; beads →\n`converting-plan-to-beads/reference/leaf-bead-checklist.md` and\n`polishing-beads/reference/readiness-checklist.md`; implementation →\nthe `implementing-beads` preflight. Skills live in `~/.agents/skills`,\n`~/.codex/skills` or `~/.claude/skills`; if one is missing, list it under\n`notChecked` and review with this file alone.\n\n**SENSITIVE BATCHES** (authentication, permissions, data, or a public\ncontract): try the abuse and edge cases — authorization bypass, session\nrevocation, check-then-write races, malformed input, unbounded resources,\nsecret exposure — and list the ones you tried in `checked`.\n\n## How you decide\n\n**BLOCKING — only when the batch is wrong or unsafe for what the user asked:**\n\n- it does not do what the request and the bead ask, or contradicts the\n PRD/design/plan it cites;\n- a bug, a failing check, or a missing test for required behaviour;\n- a change outside the bead's scope, or a contract, schema, auth or permission\n change the tier did not allow;\n- a secret, credential or unsafe command added;\n- a document or bead that forces an implementer to guess a decided value (flag\n name, error code, timeout, JSON shape);\n- a test, an assertion or an acceptance criterion weakened or deleted so that a\n check passes;\n- in a `beads` batch, or among the beads of a Medium `plan` batch, a Medium or\n Large leaf that bundles several outcomes which can be reviewed or reverted on\n their own, or that lacks Primary Proof or Reversibility.\n\n**NON-BLOCKING — everything else**: wording, naming, style, simplifications,\noptional tests, more docs, **ANY WORK THE REQUEST DID NOT ASK FOR**, and\n**HARDENING BEYOND THE REQUEST AND THE APPROVED DESIGN** — unless it is an\nexploitable defect in what was built. Never upgrade a suggestion to blocking to\nget it done. When unsure, say so and mark it non-blocking. At most three\nnon-blocking findings. An empty list is fine.\n\n**Where the line falls.** These two come from the same review of the same\nendpoint, and both sound like security:\n\n```\n- severity: blocking\n location: src/routes/admin.ts:88\n reason: POST /admin/users checks the caller's role but not that the session is still valid, so an admin whose session was revoked can still create users.\n suggestedFix: Check the session's revocation before the role, as GET /admin/users already does.\n- severity: non-blocking\n location: src/routes/admin.ts:88\n reason: POST /admin/users has no rate limit.\n suggestedFix: Consider a per-admin rate limit if the user wants one.\n```\n\nThe first is an exploitable defect in what this batch built: a revoked session\nstill works. The second adds protection nobody asked for and nothing in the\ndesign requires. Same endpoint, same topic — only the first is wrong.\n\n## Your answer\n\nYour final answer is exactly this block (`none` for empty fields), then stop:\n\n```\nBM-REVIEW\nrequestId: <requestId>\nbatchId: <batchId>\nreviewKind: first | re-review\nverdict: pass | changes-required\nchecked: <what you read and ran: document paths, bead ids, diff paths, test results, abuse cases tried>\nfindings:\n- severity: blocking | non-blocking\n location: <file:line, or bead id>\n reason: <why this is a problem>\n suggestedFix: <what the Worker should change>\nnotChecked: <anything in the scope you could not check, and why>\n```\n\n- `verdict` is `changes-required` if at least one finding is **blocking**,\n otherwise `pass`. Non-blocking findings alone never change the verdict.\n- With no findings write exactly `findings: none` — never a finding whose\n fields are all `none`.\n- `location`: `file:line`, a bead id, or a document heading.\n- `reason` and `suggestedFix`: one sentence each.\n- No counters: the plugin counts review calls.\n- A message that starts with `BM-FORMAT` is the plugin's: answer with the whole\n corrected `BM-REVIEW` block only; do not review again.\n\n## Stop\n\n**One request, one result.** After returning it, stop; a re-review arrives as a\nnew message. **If you are stopped** — by the Worker, by the user, or by the\nplugin's notice that starts with\n`STOP: The Beads Worker that created you was stopped by the user.` — call no\ntool and answer with exactly this single line, no `BM-REVIEW` block:\n\n```\nBM-REVIEW STOPPED\n```\n";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a role may run, as Paseo lists it (delta 20260921 §4.3.2–§4.3.3): the
|
|
3
|
+
* available base providers, each provider's models with their prices, and the
|
|
4
|
+
* one check every save runs before it writes anything — `roles.save-settings`
|
|
5
|
+
* for a role itself, `roles.save-fallback` for each entry of its chain.
|
|
6
|
+
*
|
|
7
|
+
* Every lookup is raced against `LOOKUP_TIMEOUT_MS`; one that fails becomes an
|
|
8
|
+
* empty list (or `null`) and costs one `console.warn` line starting
|
|
9
|
+
* `[paseo-bm]`. Only `checkRoleChoice` throws, and only a coded `DashboardError`.
|
|
10
|
+
*/
|
|
11
|
+
import { costOf } from "./model-costs";
|
|
12
|
+
import { LOOKUP_TIMEOUT_MS, TIMED_OUT, capabilityOf, modesFor, withTimeout, type ProviderCapability, type ProviderMode } from "./role-mode";
|
|
13
|
+
import { DashboardError, type BmRole, type RoleModelOption } from "../shared/contracts";
|
|
14
|
+
|
|
15
|
+
export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
16
|
+
|
|
17
|
+
export const nonEmpty = (value: unknown): string | null => (typeof value === "string" && value.trim() !== "" ? value : null);
|
|
18
|
+
|
|
19
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A `bm-*` role alias (`bm-worker`, `bm-worker/<model>`, `bm-worker-fallback-1`), not a base provider. */
|
|
24
|
+
export function isRoleAlias(provider: string): boolean {
|
|
25
|
+
return /^bm-/i.test(provider.trim());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The base providers Paseo reports as available, or `null` when it cannot say. Never throws. */
|
|
29
|
+
export async function availableProviders(paseo: unknown): Promise<Set<string> | null> {
|
|
30
|
+
const providers = (paseo as { providers?: { listAvailable?: unknown } } | null | undefined)?.providers;
|
|
31
|
+
const listAvailable = providers?.listAvailable;
|
|
32
|
+
if (typeof listAvailable !== "function") return null;
|
|
33
|
+
try {
|
|
34
|
+
const result = await withTimeout(listAvailable.call(providers) as Promise<{ providers?: unknown } | null | undefined>);
|
|
35
|
+
if (result === TIMED_OUT || !Array.isArray(result?.providers)) return null;
|
|
36
|
+
const ids = new Set<string>();
|
|
37
|
+
for (const entry of result.providers as Array<{ provider?: unknown; available?: unknown }>) {
|
|
38
|
+
if (entry?.available === true && typeof entry.provider === "string") ids.add(entry.provider);
|
|
39
|
+
}
|
|
40
|
+
return ids;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The base providers the Edit form offers: available ones, never a `bm-*` alias, sorted; `[]` when Paseo cannot say. */
|
|
47
|
+
export async function pickableProviders(paseo: unknown): Promise<string[]> {
|
|
48
|
+
const available = await availableProviders(paseo);
|
|
49
|
+
return available === null ? [] : [...available].filter((id) => !isRoleAlias(id)).sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ListedModel = Omit<RoleModelOption, "cost">;
|
|
53
|
+
|
|
54
|
+
/** Paseo's models of `provider`, without prices; `[]` (and one log line) when they cannot be read. Never throws. */
|
|
55
|
+
async function listedModelsOf(paseo: unknown, provider: string, log: (message: string) => void): Promise<ListedModel[]> {
|
|
56
|
+
const providers = (paseo as { providers?: { listModels?: unknown } } | null | undefined)?.providers;
|
|
57
|
+
const listModels = providers?.listModels;
|
|
58
|
+
if (typeof listModels !== "function") {
|
|
59
|
+
log(`[paseo-bm] this Paseo host cannot list models; Roles & models offers no model of ${provider}.`);
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const result = await withTimeout(listModels.call(providers, provider) as Promise<{ models?: unknown; error?: unknown } | null | undefined>);
|
|
64
|
+
if (result === TIMED_OUT) {
|
|
65
|
+
log(`[paseo-bm] reading the models of ${provider} took longer than ${LOOKUP_TIMEOUT_MS} ms; Roles & models offers none.`);
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const failure = nonEmpty(result?.error);
|
|
69
|
+
if (failure !== null || !Array.isArray(result?.models)) {
|
|
70
|
+
log(`[paseo-bm] could not read the models of ${provider} (${failure ?? "no models listed"}); Roles & models offers none.`);
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const out: ListedModel[] = [];
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
for (const raw of result.models as unknown[]) {
|
|
76
|
+
const model = asRecord(raw);
|
|
77
|
+
const id = nonEmpty(model?.["id"]);
|
|
78
|
+
if (model === null || id === null || seen.has(id)) continue;
|
|
79
|
+
seen.add(id);
|
|
80
|
+
const thinkingOptions: ListedModel["thinkingOptions"] = [];
|
|
81
|
+
let flaggedDefault: string | null = null;
|
|
82
|
+
const optionIds = new Set<string>();
|
|
83
|
+
for (const rawOption of Array.isArray(model["thinkingOptions"]) ? (model["thinkingOptions"] as unknown[]) : []) {
|
|
84
|
+
const option = asRecord(rawOption);
|
|
85
|
+
const optionId = nonEmpty(option?.["id"]);
|
|
86
|
+
if (option === null || optionId === null || optionIds.has(optionId)) continue;
|
|
87
|
+
optionIds.add(optionId);
|
|
88
|
+
thinkingOptions.push({ id: optionId, label: nonEmpty(option["label"]) ?? optionId });
|
|
89
|
+
if (flaggedDefault === null && option["isDefault"] === true) flaggedDefault = optionId;
|
|
90
|
+
}
|
|
91
|
+
out.push({
|
|
92
|
+
id,
|
|
93
|
+
label: nonEmpty(model["label"]) ?? id,
|
|
94
|
+
thinkingOptions,
|
|
95
|
+
defaultThinkingOptionId: nonEmpty(model["defaultThinkingOptionId"]) ?? flaggedDefault,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
log(`[paseo-bm] could not read the models of ${provider} (${reasonOf(error)}); Roles & models offers none.`);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The models with their listed rates (`costOf`, §4.2.7); a model without one has `cost: null`. */
|
|
106
|
+
export async function modelOptionsOf(paseo: unknown, provider: string, log: (message: string) => void): Promise<RoleModelOption[]> {
|
|
107
|
+
const models = await listedModelsOf(paseo, provider, log);
|
|
108
|
+
return Promise.all(
|
|
109
|
+
models.map(async (model) => ({
|
|
110
|
+
...model,
|
|
111
|
+
cost: await costOf(paseo, provider, model.id, log).catch(() => null),
|
|
112
|
+
})),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What a role runs: a base provider, one of its models, and optional thinking and mode. */
|
|
117
|
+
export interface RoleChoice {
|
|
118
|
+
baseProvider: string;
|
|
119
|
+
model: string;
|
|
120
|
+
thinkingOptionId: string | null;
|
|
121
|
+
modeId: string | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The checks of §4.3.3, before any write; each failure is
|
|
126
|
+
* `E_ROLE_SETTINGS_INVALID`: the base provider is available and is not a
|
|
127
|
+
* `bm-*` alias; the model is one Paseo lists for it; the thinking level is one
|
|
128
|
+
* of that model's; the mode is one the provider lists, and never `dangerous` /
|
|
129
|
+
* `planning` for the Reviewer. Returns the model, as listed, and the
|
|
130
|
+
* provider's capability. `available` lets a caller checking several choices
|
|
131
|
+
* read the provider list once.
|
|
132
|
+
*/
|
|
133
|
+
export async function checkRoleChoice(
|
|
134
|
+
role: BmRole,
|
|
135
|
+
choice: RoleChoice,
|
|
136
|
+
paseo: unknown,
|
|
137
|
+
log: (message: string) => void,
|
|
138
|
+
available?: Set<string> | null,
|
|
139
|
+
): Promise<{ model: RoleModelOption; capability: ProviderCapability; modes: ProviderMode[] | null }> {
|
|
140
|
+
const invalid = (detail: string) => new DashboardError("E_ROLE_SETTINGS_INVALID", detail);
|
|
141
|
+
if (isRoleAlias(choice.baseProvider)) throw invalid(`"${choice.baseProvider}" is a paseo-bm role alias, not a base provider`);
|
|
142
|
+
const listed = available === undefined ? await availableProviders(paseo) : available;
|
|
143
|
+
if (listed === null) throw invalid("Paseo cannot say which providers are available right now; try again");
|
|
144
|
+
if (!listed.has(choice.baseProvider)) throw invalid(`provider "${choice.baseProvider}" is not available in Paseo`);
|
|
145
|
+
|
|
146
|
+
const [models, modes] = await Promise.all([modelOptionsOf(paseo, choice.baseProvider, log), modesFor(paseo, choice.baseProvider, log)]);
|
|
147
|
+
const model = models.find((entry) => entry.id === choice.model);
|
|
148
|
+
if (model === undefined) throw invalid(`model "${choice.model}" is not listed for ${choice.baseProvider}`);
|
|
149
|
+
if (choice.thinkingOptionId !== null && !model.thinkingOptions.some((option) => option.id === choice.thinkingOptionId)) {
|
|
150
|
+
throw invalid(`thinking "${choice.thinkingOptionId}" is not offered by ${choice.model}`);
|
|
151
|
+
}
|
|
152
|
+
if (choice.modeId !== null) {
|
|
153
|
+
const mode = (modes ?? []).find((entry) => entry?.id === choice.modeId);
|
|
154
|
+
if (mode === undefined) throw invalid(`mode "${choice.modeId}" is not listed for ${choice.baseProvider}`);
|
|
155
|
+
const tier = typeof mode.colorTier === "string" ? mode.colorTier.toLowerCase() : "";
|
|
156
|
+
if (role === "reviewer" && (tier === "dangerous" || tier === "planning")) {
|
|
157
|
+
throw invalid(`the Reviewer never runs in a ${tier} mode ("${choice.modeId}")`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { model, capability: capabilityOf(modes), modes };
|
|
161
|
+
}
|