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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a finished turn ended on a provider-plan failure, and which class
|
|
3
|
+
* (delta 20260921 §4.4.4, REQ-065 a, b).
|
|
4
|
+
*
|
|
5
|
+
* Today the plugin ignores a failed turn (proposal M9): a Worker whose plan ran
|
|
6
|
+
* out just stops. This module looks at one ended turn of an agent whose role
|
|
7
|
+
* has a fallback chain (`FALLBACK_ROLES`) and no `bm.replacedBy` label, and
|
|
8
|
+
* reads one of two signals:
|
|
9
|
+
* - N1: the turn failed. The text is `outcome.error.message`.
|
|
10
|
+
* - N2: the turn completed without doing anything: no tool call, no
|
|
11
|
+
* `BM-REPORT` or `BM-REVIEW` block in the agent's own messages (REQ-065 a:
|
|
12
|
+
* a turn "without a BM-REPORT"; a Reviewer's verdict that woke a Worker is
|
|
13
|
+
* not the Worker's report), a last assistant message of at most 500
|
|
14
|
+
* characters, and either no output tokens or, when the provider reports no
|
|
15
|
+
* tokens, a turn that was not started by one of the plugin's notices. The
|
|
16
|
+
* text is that last message (proposal S3). The notice rule keeps a Manager's
|
|
17
|
+
* one-line answer to a `BM-FALLBACK` from reading as a failure of its own.
|
|
18
|
+
* The text is then classified (`shared/fallback-patterns.ts`); L3 and L6 mean
|
|
19
|
+
* nothing is done, as before.
|
|
20
|
+
*
|
|
21
|
+
* Where each fact comes from:
|
|
22
|
+
* - The turn's items: the hook payload, cut at its last user message
|
|
23
|
+
* (`sliceLastTurn`), as `format-check.ts` reads it. The refetch entries are
|
|
24
|
+
* not filtered by `turnId` here because Paseo reuses turn ids inside one
|
|
25
|
+
* agent (AGENTS.md), so an older turn with the same id could add its tool
|
|
26
|
+
* calls.
|
|
27
|
+
* - Labels and output tokens: the agent snapshot of one `timeline.refetch`,
|
|
28
|
+
* the same call the collector makes; `timestampsForTurn` turns it into
|
|
29
|
+
* `usage`, so "0 output tokens" means exactly what a trace record says.
|
|
30
|
+
*
|
|
31
|
+
* Checks run cheapest first, so a turn with a tool call, or whose text does not
|
|
32
|
+
* classify as a fallback class, costs no daemon call. Nothing here records an
|
|
33
|
+
* incident or reads `role-fallback.json`: the caller hands the file's
|
|
34
|
+
* `patterns` in. Never throws: a failure costs one `[paseo-bm]` log line and
|
|
35
|
+
* the turn is not classified.
|
|
36
|
+
*/
|
|
37
|
+
import type { PluginLifecycleEvents } from "@getpaseo/plugin/server";
|
|
38
|
+
import { FALLBACK_ROLES } from "../shared/fallback";
|
|
39
|
+
import { compilePatterns, isFallbackClass, matchClass, type FallbackClass, type UserPatterns } from "../shared/fallback-patterns";
|
|
40
|
+
import { roleOfAgent, roleOfProvider } from "./agent-role";
|
|
41
|
+
import { parseReports, parseReviews } from "./bm-report";
|
|
42
|
+
import { REFETCH_LIMIT, sliceLastTurn, timestampsForTurn, type CollectorPaseo } from "./collector";
|
|
43
|
+
import { isPluginNotice } from "./notices";
|
|
44
|
+
import type { Usage } from "../shared/contracts";
|
|
45
|
+
|
|
46
|
+
type TurnEndedEvent = PluginLifecycleEvents["agent.turn_ended"];
|
|
47
|
+
|
|
48
|
+
/** Label the plugin puts on an agent once a fallback agent took over from it (§4.4.7). */
|
|
49
|
+
export const REPLACED_BY_LABEL = "bm.replacedBy";
|
|
50
|
+
|
|
51
|
+
/** Longest last assistant message an N2 turn may end on. */
|
|
52
|
+
export const MAX_QUIET_REPLY_CHARS = 500;
|
|
53
|
+
|
|
54
|
+
/** A turn that ended on a provider-plan failure. */
|
|
55
|
+
export interface FallbackSignal {
|
|
56
|
+
class: FallbackClass;
|
|
57
|
+
/** N1 (`failed`) or N2 (`completed`). */
|
|
58
|
+
signal: "failed" | "completed";
|
|
59
|
+
/** The text that was classified, trimmed; not cut, not redacted. */
|
|
60
|
+
message: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FallbackDetectDeps {
|
|
64
|
+
/** Reads the agent's labels and usage; `PaseoApi` is structurally assignable. */
|
|
65
|
+
paseo: CollectorPaseo;
|
|
66
|
+
/** The `patterns` of `role-fallback.json`, read by the caller; absent means the defaults of every class. */
|
|
67
|
+
patterns?: UserPatterns | null;
|
|
68
|
+
log?: (message: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function describeError(error: unknown): string {
|
|
72
|
+
return error instanceof Error ? error.message : String(error);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isFallbackRole(role: unknown): boolean {
|
|
76
|
+
return (FALLBACK_ROLES as readonly unknown[]).includes(role);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function textItem(item: unknown): { type: string; text: string } | null {
|
|
80
|
+
if (item === null || typeof item !== "object") return null;
|
|
81
|
+
const { type, text } = item as { type?: unknown; text?: unknown };
|
|
82
|
+
if ((type !== "user_message" && type !== "assistant_message") || typeof text !== "string") return null;
|
|
83
|
+
return { type, text };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when a message carries a `BM-REPORT` or `BM-REVIEW` block, read the way the collector reads them. */
|
|
87
|
+
function hasBlock(text: string): boolean {
|
|
88
|
+
const context = { agentId: "", at: "" };
|
|
89
|
+
return parseReports(text, context).length > 0 || parseReviews(text, context).length > 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The last assistant message of a turn that did nothing else (N2 conditions
|
|
94
|
+
* 1 to 3), trimmed, or null: the turn has a tool call, an assistant message
|
|
95
|
+
* with a block, no assistant message, or a last one that is empty or longer
|
|
96
|
+
* than `MAX_QUIET_REPLY_CHARS`. A block in a message the agent RECEIVED does
|
|
97
|
+
* not count: a Worker woken by its Reviewer's `BM-REVIEW` can still hit its limit.
|
|
98
|
+
*/
|
|
99
|
+
export function quietReply(items: readonly unknown[]): string | null {
|
|
100
|
+
let last: string | null = null;
|
|
101
|
+
for (const item of items) {
|
|
102
|
+
if ((item as { type?: unknown } | null)?.type === "tool_call") return null;
|
|
103
|
+
const message = textItem(item);
|
|
104
|
+
if (message === null || message.type !== "assistant_message") continue;
|
|
105
|
+
if (hasBlock(message.text)) return null;
|
|
106
|
+
last = message.text;
|
|
107
|
+
}
|
|
108
|
+
const reply = last?.trim() ?? "";
|
|
109
|
+
return reply !== "" && reply.length <= MAX_QUIET_REPLY_CHARS ? reply : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* N2 condition 4: the turn produced no output tokens, or, when the provider
|
|
114
|
+
* reports none (`usage` null), the message that started it is not one of the
|
|
115
|
+
* plugin's notices. A turn with no user message counts as not started by one.
|
|
116
|
+
*/
|
|
117
|
+
export function quietTokens(usage: Usage | null, items: readonly unknown[]): boolean {
|
|
118
|
+
if (usage !== null) return usage.outputTokens === 0;
|
|
119
|
+
const first = items.length > 0 ? textItem(items[0]) : null;
|
|
120
|
+
return !isPluginNotice(first?.type === "user_message" ? first.text : null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The agent's labels and usage from one `timeline.refetch`. The payload is
|
|
125
|
+
* handed to the collector's own reader so the usage is read exactly as a trace
|
|
126
|
+
* record's. Throws when the call fails or the payload carries no snapshot.
|
|
127
|
+
*/
|
|
128
|
+
async function readAgent(
|
|
129
|
+
paseo: CollectorPaseo,
|
|
130
|
+
agentId: string,
|
|
131
|
+
turnId: string | null,
|
|
132
|
+
): Promise<{ labels: Record<string, unknown>; usage: Usage | null }> {
|
|
133
|
+
const payload = await paseo.agents.ref(agentId).timeline.refetch({ direction: "tail", limit: REFETCH_LIMIT });
|
|
134
|
+
const snapshot = (payload as { agent?: unknown } | null)?.agent;
|
|
135
|
+
if (snapshot === null || typeof snapshot !== "object") throw new Error("the timeline refetch returned no agent snapshot");
|
|
136
|
+
const replay: CollectorPaseo = { agents: { ref: () => ({ timeline: { refetch: async () => payload } }) } };
|
|
137
|
+
const { usage } = await timestampsForTurn({ location: null, paseo: replay }, agentId, turnId);
|
|
138
|
+
const labels = (snapshot as { labels?: unknown }).labels;
|
|
139
|
+
return { labels: labels !== null && typeof labels === "object" ? (labels as Record<string, unknown>) : {}, usage };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Classifies one ended turn. Returns the class, the signal and the text when
|
|
144
|
+
* the turn ended on an L1, L2, L4 or L5 failure; null otherwise, including
|
|
145
|
+
* for a canceled turn, an agent outside `FALLBACK_ROLES`, an agent already
|
|
146
|
+
* replaced, and any failure (logged once).
|
|
147
|
+
*
|
|
148
|
+
* The event carries no labels, so the provider filters first: an agent whose
|
|
149
|
+
* provider is not a paseo-bm alias of a fallback role is never fetched. The
|
|
150
|
+
* snapshot's labels then decide as `roleOfAgent` does, the `bm.role` label
|
|
151
|
+
* winning over the provider.
|
|
152
|
+
*/
|
|
153
|
+
export async function classifyTurn(event: TurnEndedEvent, deps: FallbackDetectDeps): Promise<FallbackSignal | null> {
|
|
154
|
+
const log = deps.log ?? ((message: string) => console.warn(message));
|
|
155
|
+
let agentId = "(unknown)";
|
|
156
|
+
try {
|
|
157
|
+
const agent = event.agent;
|
|
158
|
+
agentId = typeof agent?.id === "string" ? agent.id : agentId;
|
|
159
|
+
const outcome = event.outcome;
|
|
160
|
+
if (outcome?.kind !== "failed" && outcome?.kind !== "completed") return null;
|
|
161
|
+
if (!isFallbackRole(roleOfProvider(agent.provider))) return null;
|
|
162
|
+
|
|
163
|
+
const items = sliceLastTurn(Array.isArray(event.timeline) ? event.timeline : []);
|
|
164
|
+
const message =
|
|
165
|
+
outcome.kind === "failed"
|
|
166
|
+
? typeof outcome.error?.message === "string"
|
|
167
|
+
? outcome.error.message.trim()
|
|
168
|
+
: ""
|
|
169
|
+
: quietReply(items);
|
|
170
|
+
if (message === null || message === "") return null;
|
|
171
|
+
const found = matchClass(message, compilePatterns(deps.patterns, log));
|
|
172
|
+
if (!isFallbackClass(found)) return null;
|
|
173
|
+
|
|
174
|
+
const { labels, usage } = await readAgent(deps.paseo, agent.id, event.turnId);
|
|
175
|
+
if (!isFallbackRole(roleOfAgent({ provider: agent.provider, labels })?.role)) return null;
|
|
176
|
+
if (Object.prototype.hasOwnProperty.call(labels, REPLACED_BY_LABEL)) return null;
|
|
177
|
+
if (outcome.kind === "completed" && !quietTokens(usage, items)) return null;
|
|
178
|
+
return { class: found, signal: outcome.kind, message };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
log(`[paseo-bm] fallback detection skipped a turn of agent ${agentId}: ${describeError(error)}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handovers a replacement agent starts from (delta 20260921 §4.4.8,
|
|
3
|
+
* §4.5.2, REQ-065 d, REQ-066 c), built in code from stored data — never
|
|
4
|
+
* summarised by a model.
|
|
5
|
+
*
|
|
6
|
+
* Worker (`workerHandover`):
|
|
7
|
+
*
|
|
8
|
+
* | Field | Source |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | `requestId` | the replaced Worker's `bm.requestId` label, recorded on the incident |
|
|
11
|
+
* | report fields | the LATEST `BM-REPORT` of that request in the workspace trace store |
|
|
12
|
+
* | `reviewCalls` | the request's reconstructed trace, against its tier's budget; a replacement Reviewer's first message is not counted (§4.5.1) |
|
|
13
|
+
* | original request | the first `user_message` of the replaced Worker's timeline |
|
|
14
|
+
*
|
|
15
|
+
* Manager (`managerHandover`):
|
|
16
|
+
*
|
|
17
|
+
* | Field | Source |
|
|
18
|
+
* |---|---|
|
|
19
|
+
* | `workers` | live, non-replaced Workers of the workspace (`bmAgentsOf`, `liveWorkersOf`); provider and model from each one's snapshot; last report as for the Worker |
|
|
20
|
+
* | `openQuestions` | `waitingOf` (the pill's rule) on the old Manager's timeline |
|
|
21
|
+
* | `openIncidents` | the workspace's other `pending` or `waiting` incidents |
|
|
22
|
+
* | user's messages | the three newest `user_message` items WITH `clientMessageId` (the user's own words) in the old Manager's timeline |
|
|
23
|
+
*
|
|
24
|
+
* Each source is raced against the budget (5 s by default): a missing part
|
|
25
|
+
* reads `unknown` (`unavailable` for the Worker's request text) and never
|
|
26
|
+
* blocks the switch. Every quoted user text is masked like every trace record
|
|
27
|
+
* (REQ-048b).
|
|
28
|
+
*/
|
|
29
|
+
import { peersOfWorkspace } from "./chat-peers";
|
|
30
|
+
import { WAITING_TIMELINE_LIMIT, WAITING_TIMELINE_PAGES, waitingOf, type WaitingEntry } from "./chat-waiting";
|
|
31
|
+
import { redactText } from "./collector";
|
|
32
|
+
import { agentFactsOf, bmAgentsOf, reviewerReplacementIds, reviewerReplacementsFor, type DashboardPaseo } from "./dashboard-rpc";
|
|
33
|
+
import { replacementsOf } from "./fallback-state";
|
|
34
|
+
import { LIVE_MAX_PAGES, LIVE_PAGE_LIMIT, readTimelinePages, type LiveTimelinePaseo } from "./live-timeline";
|
|
35
|
+
import { isPluginNotice } from "./notices";
|
|
36
|
+
import { providerId } from "./provider-id";
|
|
37
|
+
import { REVIEW_BUDGET } from "./review-budget";
|
|
38
|
+
import { asRecord, nonEmpty } from "./role-choices";
|
|
39
|
+
import { TRUNCATION_MARKER, readRecords, type TraceStoreLocation } from "./trace-store";
|
|
40
|
+
import { reconstructTraces } from "./traces";
|
|
41
|
+
import { FALLBACK_CLASS_LABELS } from "../shared/bm-fallback";
|
|
42
|
+
import { parseQuestions } from "../shared/bm-questions";
|
|
43
|
+
import type { ChatPeer, FallbackIncident, ParsedReport, TraceRecord, WaitingWorker } from "../shared/contracts";
|
|
44
|
+
|
|
45
|
+
/** First line of the handover. */
|
|
46
|
+
export const HANDOVER_MARKER = "BM-HANDOVER";
|
|
47
|
+
|
|
48
|
+
/** Budget of the whole handover (§4.4.8). */
|
|
49
|
+
export const HANDOVER_BUDGET_MS = 5000;
|
|
50
|
+
|
|
51
|
+
/** Longest failure message quoted in the `reason` line. */
|
|
52
|
+
const REASON_MESSAGE_CHARS = 300;
|
|
53
|
+
|
|
54
|
+
/** Longest `blockers` text of a Manager handover's Worker line. */
|
|
55
|
+
const BLOCKERS_CHARS = 300;
|
|
56
|
+
|
|
57
|
+
/** The user's newest messages a Manager handover quotes, and the longest one. */
|
|
58
|
+
const QUOTED_MESSAGES = 3;
|
|
59
|
+
const QUOTED_MESSAGE_CHARS = 1000;
|
|
60
|
+
|
|
61
|
+
/** Incidents a new Manager inherits: still to be decided, or waiting for a reset. */
|
|
62
|
+
const OPEN_INCIDENT_STATUSES: ReadonlySet<FallbackIncident["status"]> = new Set(["pending", "waiting"]);
|
|
63
|
+
|
|
64
|
+
/** Last paragraph of the Manager handover (§4.5.2). */
|
|
65
|
+
const MANAGER_CLOSING =
|
|
66
|
+
"You are the Beads Manager of this workspace from now on. Every Worker listed was told your id. Tell the user in one line that you took over, then carry on.";
|
|
67
|
+
|
|
68
|
+
export interface HandoverDeps {
|
|
69
|
+
/** Agent listing and timelines; `PaseoApi` is structurally assignable. */
|
|
70
|
+
paseo: unknown;
|
|
71
|
+
/** The workspace trace store; `null` when tracing is off (the report fields read `unknown`). */
|
|
72
|
+
location: TraceStoreLocation | null;
|
|
73
|
+
log?: (message: string) => void;
|
|
74
|
+
budgetMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Every recorded incident (`readIncidents`). The Worker handover counts
|
|
77
|
+
* `reviewCalls` without the first message of each Reviewer that replaced a
|
|
78
|
+
* stopped one (§4.5.1); read from the install home when absent, none when `null`.
|
|
79
|
+
*/
|
|
80
|
+
incidents?: readonly FallbackIncident[] | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `work()` within `budget` ms, or `null` on timeout or failure. Never throws. */
|
|
84
|
+
async function within<T>(budget: number, work: () => Promise<T> | T): Promise<T | null> {
|
|
85
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
86
|
+
try {
|
|
87
|
+
return await Promise.race([
|
|
88
|
+
Promise.resolve().then(work),
|
|
89
|
+
new Promise<null>((resolve) => {
|
|
90
|
+
timer = setTimeout(() => resolve(null), budget);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The request's latest `BM-REPORT` in the trace store, or null. */
|
|
102
|
+
function latestReport(records: readonly TraceRecord[], requestId: string): ParsedReport | null {
|
|
103
|
+
let latest: ParsedReport | null = null;
|
|
104
|
+
for (const record of records) {
|
|
105
|
+
for (const report of record.reports) {
|
|
106
|
+
if (report.requestId !== requestId) continue;
|
|
107
|
+
if (latest === null || report.at >= latest.at) latest = report;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return latest;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The first `user_message` of an agent's timeline (bounded walk back to its start), masked; null when none. */
|
|
114
|
+
async function firstUserMessage(paseo: unknown, agentId: string): Promise<string | null> {
|
|
115
|
+
let first: string | null = null;
|
|
116
|
+
await readTimelinePages(paseo as LiveTimelinePaseo, agentId, { pages: LIVE_MAX_PAGES, limit: LIVE_PAGE_LIMIT }, (entries) => {
|
|
117
|
+
// Pages come newest first, each oldest first: every older page's first message replaces the last one found.
|
|
118
|
+
const found = entries
|
|
119
|
+
.map((entry) => entry.item as { type?: unknown; text?: unknown } | undefined)
|
|
120
|
+
.find((item) => item?.type === "user_message" && typeof item.text === "string" && item.text.trim() !== "");
|
|
121
|
+
if (found !== undefined) first = found.text as string;
|
|
122
|
+
});
|
|
123
|
+
return first === null ? null : redactText(first);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const list = (values: readonly string[] | undefined): string => (values === undefined ? "unknown" : values.length === 0 ? "none" : values.join(", "));
|
|
127
|
+
|
|
128
|
+
/** `text` on one line, at most `max` characters. */
|
|
129
|
+
const oneLine = (text: string, max: number): string => text.replace(/\s+/g, " ").trim().slice(0, max);
|
|
130
|
+
|
|
131
|
+
/** The `reason` line: the failure class and the provider's own words. */
|
|
132
|
+
const reasonLine = (incident: FallbackIncident): string =>
|
|
133
|
+
`reason: ${FALLBACK_CLASS_LABELS[incident.class]} — "${oneLine(incident.message, REASON_MESSAGE_CHARS)}"`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `BM-HANDOVER` block that starts the replacement of `incident`'s Worker.
|
|
137
|
+
* Never throws: every part it cannot get in time reads `unknown`.
|
|
138
|
+
*/
|
|
139
|
+
export async function workerHandover(incident: FallbackIncident, deps: HandoverDeps): Promise<string> {
|
|
140
|
+
const budget = deps.budgetMs ?? HANDOVER_BUDGET_MS;
|
|
141
|
+
const requestId = incident.requestId;
|
|
142
|
+
const records =
|
|
143
|
+
deps.location === null ? null : await within(budget, () => readRecords(deps.location!, incident.workspaceId).records);
|
|
144
|
+
const [report, review, original] = await Promise.all([
|
|
145
|
+
within(budget, () => (records === null || requestId === null ? null : latestReport(records, requestId))),
|
|
146
|
+
within(budget, async () => {
|
|
147
|
+
if (records === null || requestId === null) return null;
|
|
148
|
+
// The same count as the BM-BUDGET check (delta 20260921 §4.5.1).
|
|
149
|
+
const [facts, replacementIds] = await Promise.all([
|
|
150
|
+
agentFactsOf(deps.paseo as DashboardPaseo, incident.workspaceId),
|
|
151
|
+
deps.incidents === undefined ? reviewerReplacementsFor(deps.paseo) : reviewerReplacementIds(deps.incidents ?? []),
|
|
152
|
+
]);
|
|
153
|
+
return reconstructTraces({ records, agents: [...facts.values()], replacementIds }).find((trace) => trace.requestId === requestId) ?? null;
|
|
154
|
+
}),
|
|
155
|
+
within(budget, () => firstUserMessage(deps.paseo, incident.agentId)),
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
const tier = report?.tier ?? review?.tier ?? null;
|
|
159
|
+
const calls = review?.reviewCalls ?? null;
|
|
160
|
+
const reviewCalls = calls === null ? "unknown" : tier === null ? `${calls} of an unknown budget` : `${calls} of ${REVIEW_BUDGET[tier]}`;
|
|
161
|
+
|
|
162
|
+
return [
|
|
163
|
+
HANDOVER_MARKER,
|
|
164
|
+
"role: worker",
|
|
165
|
+
`requestId: ${requestId ?? "unknown"}`,
|
|
166
|
+
`managerAgentId: ${incident.managerId ?? "none"}`,
|
|
167
|
+
`replaces: ${incident.agentId}`,
|
|
168
|
+
reasonLine(incident),
|
|
169
|
+
`lastReport: ${report === null ? "none" : `${report.phase ?? "unknown"} at ${report.at}`}`,
|
|
170
|
+
`tier: ${tier ?? "unknown"}`,
|
|
171
|
+
`filesChanged: ${list(report?.filesChanged)}`,
|
|
172
|
+
`beadsCreated: ${list(report?.beadsCreated)}`,
|
|
173
|
+
`beadsUpdated: ${list(report?.beadsUpdated)}`,
|
|
174
|
+
`beadsClosed: ${list(report?.beadsClosed)}`,
|
|
175
|
+
`beadsReady: ${list(report?.beadsReady)}`,
|
|
176
|
+
`reviewFindingsOpen: ${report === null ? "unknown" : (report.reviewFindingsOpen ?? "none")}`,
|
|
177
|
+
`reviewCalls: ${reviewCalls}`,
|
|
178
|
+
`skillsUsed: ${list(report?.skillsUsed)}`,
|
|
179
|
+
"",
|
|
180
|
+
"Original request (verbatim, the first message the replaced Worker received):",
|
|
181
|
+
original ?? "unavailable",
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Manager (§4.5.2, REQ-066 c).
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface ManagerHandoverDeps extends HandoverDeps {
|
|
190
|
+
/**
|
|
191
|
+
* Every recorded incident (`readIncidents`): the workspace's open ones, and
|
|
192
|
+
* the agents a `switched` one replaced. `null` when the file cannot be read
|
|
193
|
+
* (`openIncidents: unknown`).
|
|
194
|
+
*/
|
|
195
|
+
incidents: readonly FallbackIncident[] | null;
|
|
196
|
+
/** Whose secret values are masked in the quoted messages; `process.env` by default. */
|
|
197
|
+
env?: NodeJS.ProcessEnv;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The SDK slice that reads one agent's snapshot; `PaseoApi` is structurally assignable. */
|
|
201
|
+
interface SnapshotPaseo {
|
|
202
|
+
agents: { ref?(agentId: string): { refresh?(): Promise<{ agent?: unknown } | null> } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** What a Manager handover reads from the old Manager's timeline, filled page by page. */
|
|
206
|
+
interface ManagerTimeline {
|
|
207
|
+
/** Pages read so far. */
|
|
208
|
+
pages: number;
|
|
209
|
+
/** The pill's window (`WAITING_TIMELINE_PAGES`), newest first, as `waitingOf` wants it. */
|
|
210
|
+
recent: WaitingEntry[];
|
|
211
|
+
/** The user's own messages, newest first, masked and cut; at most `QUOTED_MESSAGES`. */
|
|
212
|
+
typed: string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The Workers a Manager handover lists — and so the ones the switch tells the
|
|
217
|
+
* new Manager's id (§4.5.2 step 5): live (neither archived nor closed) and not
|
|
218
|
+
* replaced by a fallback Worker.
|
|
219
|
+
*/
|
|
220
|
+
export function liveWorkersOf<P extends Pick<ChatPeer, "role" | "status" | "archived" | "replaced">>(peers: readonly P[]): P[] {
|
|
221
|
+
return peers.filter((peer) => peer.role === "worker" && !peer.archived && peer.status !== "closed" && !peer.replaced);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The text of a message the user typed in the app: a `user_message` with
|
|
226
|
+
* `clientMessageId` (AGENTS.md), and neither a plugin notice nor a handover.
|
|
227
|
+
*/
|
|
228
|
+
function typedText(item: unknown): string | null {
|
|
229
|
+
const message = item as { type?: unknown; text?: unknown; clientMessageId?: unknown } | null | undefined;
|
|
230
|
+
if (message?.type !== "user_message" || typeof message.clientMessageId !== "string") return null;
|
|
231
|
+
const text = nonEmpty(message.text);
|
|
232
|
+
return text === null || isPluginNotice(text) || text.startsWith(HANDOVER_MARKER) ? null : text;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A quoted user message: masked first, so a secret across the cut is masked whole, then cut. */
|
|
236
|
+
function quoted(text: string, env: NodeJS.ProcessEnv): string {
|
|
237
|
+
const masked = redactText(text, env);
|
|
238
|
+
if (masked.length <= QUOTED_MESSAGE_CHARS) return masked;
|
|
239
|
+
return `${masked.slice(0, QUOTED_MESSAGE_CHARS - TRUNCATION_MARKER.length)}${TRUNCATION_MARKER}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reads the old Manager's timeline back to its start (bounded) into `into`:
|
|
244
|
+
* the pill's window and the user's newest messages. Filled as pages arrive,
|
|
245
|
+
* so what was read before the budget ran out still counts.
|
|
246
|
+
*/
|
|
247
|
+
async function readManagerTimeline(paseo: unknown, managerId: string, into: ManagerTimeline, env: NodeJS.ProcessEnv): Promise<true> {
|
|
248
|
+
await readTimelinePages(paseo as LiveTimelinePaseo, managerId, { pages: LIVE_MAX_PAGES, limit: WAITING_TIMELINE_LIMIT }, (entries) => {
|
|
249
|
+
// Pages come newest first, each oldest first.
|
|
250
|
+
const newestFirst = [...entries].reverse();
|
|
251
|
+
if (into.pages < WAITING_TIMELINE_PAGES) into.recent.push(...newestFirst);
|
|
252
|
+
into.pages += 1;
|
|
253
|
+
for (const entry of newestFirst) {
|
|
254
|
+
if (into.typed.length >= QUOTED_MESSAGES) break;
|
|
255
|
+
const text = typedText(entry.item);
|
|
256
|
+
if (text !== null) into.typed.push(quoted(text, env));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** An agent's current snapshot, or null. */
|
|
263
|
+
async function snapshotOf(paseo: unknown, agentId: string): Promise<Record<string, unknown> | null> {
|
|
264
|
+
const ref = (paseo as SnapshotPaseo).agents.ref?.(agentId);
|
|
265
|
+
return asRecord((await ref?.refresh?.())?.agent);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One `workers:` line; every part it cannot know reads `unknown`. */
|
|
269
|
+
function workerLine(worker: ChatPeer, records: readonly TraceRecord[] | null, snapshot: Record<string, unknown> | null): string {
|
|
270
|
+
const provider = providerId(nonEmpty(snapshot?.["provider"]));
|
|
271
|
+
// The model the agent runs: `runtimeInfo.model` first (AGENTS.md), else the configured one.
|
|
272
|
+
const model = nonEmpty(asRecord(snapshot?.["runtimeInfo"])?.["model"]) ?? nonEmpty(snapshot?.["model"]);
|
|
273
|
+
const runsOn = provider === null && model === null ? "unknown" : `${provider ?? "unknown"}/${model ?? "unknown"}`;
|
|
274
|
+
const report = records === null || worker.requestId === null ? undefined : latestReport(records, worker.requestId);
|
|
275
|
+
const last = report === undefined ? "unknown" : report === null ? "none" : `${report.phase ?? "unknown"} at ${report.at}`;
|
|
276
|
+
const blockers = report === undefined ? "unknown" : report === null || report.blockers === null ? "none" : oneLine(report.blockers, BLOCKERS_CHARS);
|
|
277
|
+
return `- ${worker.id} · requestId ${worker.requestId ?? "unknown"} · ${runsOn} · ${worker.status} · last report: ${last} · blockers: ${blockers}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `workerId: Q1, Q2; …` for the Workers waiting on the user, or `none`. */
|
|
281
|
+
function questionsOf(waiting: readonly WaitingWorker[]): string {
|
|
282
|
+
const parts = waiting.map((entry) => `${entry.workerId}: ${(parseQuestions(entry.text)?.questions ?? []).map((question) => question.id).join(", ")}`);
|
|
283
|
+
return parts.length === 0 ? "none" : parts.join("; ");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The workspace's other open incidents, oldest first, or `none`; `unknown` without the incidents file. */
|
|
287
|
+
function openIncidentsOf(incident: FallbackIncident, incidents: readonly FallbackIncident[] | null): string {
|
|
288
|
+
if (incidents === null) return "unknown";
|
|
289
|
+
const open = incidents.filter(
|
|
290
|
+
(entry) => entry.id !== incident.id && entry.workspaceId === incident.workspaceId && OPEN_INCIDENT_STATUSES.has(entry.status),
|
|
291
|
+
);
|
|
292
|
+
return open.length === 0 ? "none" : open.map((entry) => entry.id).join(", ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `BM-HANDOVER` block that starts the replacement of `incident`'s Manager.
|
|
297
|
+
* One budget for the whole block: the old Manager's timeline is read beside
|
|
298
|
+
* the agent list and the trace store, then the Workers' snapshots in what is
|
|
299
|
+
* left — so a slow timeline never starves the Worker lines. Never throws:
|
|
300
|
+
* every part it cannot get in time reads `unknown`, with one log line naming
|
|
301
|
+
* what was late.
|
|
302
|
+
*/
|
|
303
|
+
export async function managerHandover(incident: FallbackIncident, deps: ManagerHandoverDeps): Promise<string> {
|
|
304
|
+
const budget = deps.budgetMs ?? HANDOVER_BUDGET_MS;
|
|
305
|
+
const deadline = Date.now() + budget;
|
|
306
|
+
const left = (): number => Math.max(0, deadline - Date.now());
|
|
307
|
+
const log = deps.log ?? ((message: string) => console.warn(message));
|
|
308
|
+
const { workspaceId, agentId: managerId } = incident;
|
|
309
|
+
const timeline: ManagerTimeline = { pages: 0, recent: [], typed: [] };
|
|
310
|
+
|
|
311
|
+
const walking = within(budget, () => readManagerTimeline(deps.paseo, managerId, timeline, deps.env ?? process.env));
|
|
312
|
+
const [records, all] = await Promise.all([
|
|
313
|
+
deps.location === null ? null : within(left(), () => readRecords(deps.location!, workspaceId).records),
|
|
314
|
+
within(left(), () => bmAgentsOf(deps.paseo as DashboardPaseo)),
|
|
315
|
+
]);
|
|
316
|
+
// An empty list is a failed one: the old Manager itself is always on it.
|
|
317
|
+
const agents = all === null || all.length === 0 ? null : all;
|
|
318
|
+
const peers =
|
|
319
|
+
agents === null ? null : await within(left(), () => peersOfWorkspace(agents, workspaceId, async () => records ?? [], replacementsOf(deps.incidents ?? [])));
|
|
320
|
+
const workers = peers === null ? null : liveWorkersOf(peers);
|
|
321
|
+
const snapshots = workers === null ? [] : await Promise.all(workers.map((worker) => within(left(), () => snapshotOf(deps.paseo, worker.id))));
|
|
322
|
+
const walked = await walking;
|
|
323
|
+
|
|
324
|
+
// The pill's rule, on the pill's window of the old Manager's timeline.
|
|
325
|
+
const windowRead = timeline.pages > 0 && (walked !== null || timeline.pages >= WAITING_TIMELINE_PAGES);
|
|
326
|
+
const waiting =
|
|
327
|
+
peers === null || !windowRead
|
|
328
|
+
? null
|
|
329
|
+
: await within(left(), () =>
|
|
330
|
+
waitingOf(
|
|
331
|
+
{ id: managerId, workspaceId },
|
|
332
|
+
timeline.recent,
|
|
333
|
+
peers.filter((peer) => peer.role === "worker").map((peer) => ({ ...peer, workspaceId })),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
const typed = [...timeline.typed].reverse();
|
|
337
|
+
const messages = typed.length > 0 ? typed.map((text, index) => `${index + 1}. ${text}`) : [timeline.pages > 0 && walked !== null ? "none" : "unknown"];
|
|
338
|
+
|
|
339
|
+
const late = [
|
|
340
|
+
...(deps.location !== null && records === null ? ["the trace store"] : []),
|
|
341
|
+
...(agents === null || peers === null ? ["the agent list"] : []),
|
|
342
|
+
...(timeline.pages === 0 ? [`the timeline of Manager ${managerId}`] : []),
|
|
343
|
+
];
|
|
344
|
+
if (late.length > 0) log(`[paseo-bm] the handover of incident ${incident.id} could not read ${late.join(", ")} within ${budget} ms; those parts read unknown.`);
|
|
345
|
+
|
|
346
|
+
return [
|
|
347
|
+
HANDOVER_MARKER,
|
|
348
|
+
"role: manager",
|
|
349
|
+
`workspaceId: ${workspaceId}`,
|
|
350
|
+
`replaces: ${managerId}`,
|
|
351
|
+
reasonLine(incident),
|
|
352
|
+
...(workers === null
|
|
353
|
+
? ["workers: unknown"]
|
|
354
|
+
: workers.length === 0
|
|
355
|
+
? ["workers: none"]
|
|
356
|
+
: ["workers:", ...workers.map((worker, index) => workerLine(worker, records, snapshots[index] ?? null))]),
|
|
357
|
+
`openQuestions: ${waiting === null ? "unknown" : questionsOf(waiting)}`,
|
|
358
|
+
`openIncidents: ${openIncidentsOf(incident, deps.incidents)}`,
|
|
359
|
+
"",
|
|
360
|
+
"The user's last messages to the Manager you replace (oldest first, verbatim):",
|
|
361
|
+
...messages,
|
|
362
|
+
"",
|
|
363
|
+
MANAGER_CLOSING,
|
|
364
|
+
].join("\n");
|
|
365
|
+
}
|