pipeline-moe 0.1.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/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- package/web/tsconfig.json +21 -0
package/src/room.ts
ADDED
|
@@ -0,0 +1,1657 @@
|
|
|
1
|
+
// The Room: shared transcript + serial turn queue + @mention routing + work
|
|
2
|
+
// receipts. One room per process. All model work is serialised here, which
|
|
3
|
+
// also matches llama-server running with --parallel 1.
|
|
4
|
+
|
|
5
|
+
import { config } from "./config.js"
|
|
6
|
+
import { diffSnapshots, listWorkspace, receiptFromActivity, receiptHasChanges, snapshot } from "./receipts.js"
|
|
7
|
+
import type { Registry } from "./registry.js"
|
|
8
|
+
import type { Participant } from "./participant.js"
|
|
9
|
+
import type { ConversationStore } from "./store.js"
|
|
10
|
+
import { conversationMeta } from "./store.js"
|
|
11
|
+
import type { SseHub, SseEventName } from "./sse.js"
|
|
12
|
+
import type { LocalModelLock } from "./local-model-lock.js"
|
|
13
|
+
import { REPEAT_THRESHOLD, SIMILARITY_FLOOR, textSimilarity, LOOKBACK_WINDOW, checkToolLoop, TOOL_REPEAT_THRESHOLD } from "./circuit-breaker.js"
|
|
14
|
+
import { goalEvalPrompt } from "./personas.js"
|
|
15
|
+
import type {
|
|
16
|
+
Conversation,
|
|
17
|
+
ConversationMeta,
|
|
18
|
+
Persona,
|
|
19
|
+
RouteDecision,
|
|
20
|
+
RoutingMode,
|
|
21
|
+
ToolActivity,
|
|
22
|
+
TranscriptEntry,
|
|
23
|
+
WorkReceipt,
|
|
24
|
+
} from "./types.js"
|
|
25
|
+
|
|
26
|
+
/** Produce a compact one-line summary of a work receipt for injection into the next agent's context. */
|
|
27
|
+
function formatReceipt(r: WorkReceipt): string {
|
|
28
|
+
const parts: string[] = []
|
|
29
|
+
if (r.created.length > 0) parts.push(`created: ${r.created.join(", ")}`)
|
|
30
|
+
if (r.modified.length > 0) parts.push(`modified: ${r.modified.join(", ")}`)
|
|
31
|
+
if (r.deleted.length > 0) parts.push(`deleted: ${r.deleted.join(", ")}`)
|
|
32
|
+
return `📋 Work receipt from @${r.participantId}: ${parts.join("; ")}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** State when the pipeline is paused waiting for a user response to an ask_user. */
|
|
36
|
+
interface PendingQuestion {
|
|
37
|
+
askerId: string
|
|
38
|
+
heldQueue: Participant[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A proposed handoff awaiting human approval (semi/manual routing). */
|
|
42
|
+
interface RouteProposal {
|
|
43
|
+
fromId: string
|
|
44
|
+
target: Participant
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** State when routing is paused for human approval (semi/manual mode). The
|
|
48
|
+
* heldQueue is work already queued before the proposal; it resumes once the
|
|
49
|
+
* human approves / redirects / drops. */
|
|
50
|
+
interface PendingRoute {
|
|
51
|
+
proposals: RouteProposal[]
|
|
52
|
+
heldQueue: Participant[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MENTION_RE = /@(\w+)/g
|
|
56
|
+
|
|
57
|
+
/** What one agent produced in a turn, before it is posted to the transcript. */
|
|
58
|
+
interface RunOutput {
|
|
59
|
+
target: Participant
|
|
60
|
+
reply: string
|
|
61
|
+
activity: ToolActivity[]
|
|
62
|
+
reasoning?: string
|
|
63
|
+
receipt: WorkReceipt
|
|
64
|
+
/** If the agent called ask_user, the question text. */
|
|
65
|
+
question?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function newConvId(): string {
|
|
69
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class Room {
|
|
73
|
+
private transcript: TranscriptEntry[] = []
|
|
74
|
+
private chain: Promise<void> = Promise.resolve()
|
|
75
|
+
/** Agents currently mid-turn. >1 when a parallel wave is running. */
|
|
76
|
+
private running = new Set<Participant>()
|
|
77
|
+
/** Id of the first agent that started the current turn. Used for UI targeting (steer). */
|
|
78
|
+
private runningAgentId: string | null = null
|
|
79
|
+
/** Pending agents to run in the current routing pass. Mutated as agents chain. */
|
|
80
|
+
private queue: Participant[] = []
|
|
81
|
+
private aborted = false
|
|
82
|
+
/** Routing mode. 'auto' chains @mentions directly (today's default); 'semi'
|
|
83
|
+
* pauses each proposed handoff for human approval; 'manual' honors no
|
|
84
|
+
* agent→agent chaining. The legacy `chaining` boolean is derived from this
|
|
85
|
+
* (auto/semi → on, manual → off) so existing settings, persistence, and tests
|
|
86
|
+
* keep working unchanged. */
|
|
87
|
+
private routingMode: RoutingMode = "auto"
|
|
88
|
+
private get chaining(): boolean { return this.routingMode !== "manual" }
|
|
89
|
+
private set chaining(value: boolean) { this.routingMode = value ? "auto" : "manual" }
|
|
90
|
+
/** Anti-loop: max chain hops per turn. Prevents A→B→A infinite loops. */
|
|
91
|
+
private maxChainHops = 30
|
|
92
|
+
private chainBudget = 0
|
|
93
|
+
/** Set when an agent called ask_user — pipeline is paused until user responds. */
|
|
94
|
+
private pendingQuestion: PendingQuestion | null = null
|
|
95
|
+
/** Set in semi/manual mode when proposed handoffs await human approval. */
|
|
96
|
+
private pendingRoute: PendingRoute | null = null
|
|
97
|
+
/** Agent that handles messages with no @mention. null = first active. */
|
|
98
|
+
private defaultAgentId: string | null = null
|
|
99
|
+
/** Agent that receives routing fallback when no agent is @-mentioned in a reply. null = disabled. */
|
|
100
|
+
private fallbackAgentId: string | null = "planner"
|
|
101
|
+
/** Agent whose circuit breaker tripped — used for fallback recovery routing. null = no breaker event. */
|
|
102
|
+
private circuitBreakerAgentId: string | null = null
|
|
103
|
+
/** Goal prompt if this room was started with a goal; null for interactive rooms. */
|
|
104
|
+
private goalText: string | null = null
|
|
105
|
+
/** Lifecycle status for goal-driven rooms. */
|
|
106
|
+
private goalStatus: "idle" | "running" | "completed" | "failed" | "cancelled" = "idle"
|
|
107
|
+
/** Set by abortCurrent() while a goal is running. Sticky for the whole goal
|
|
108
|
+
* run — NOT reset per eval iteration — so the goal-eval loop (which clears
|
|
109
|
+
* `aborted` on every pass) still terminates as "cancelled" instead of spinning
|
|
110
|
+
* to the next iteration. Cleared by submitGoal() for the next goal. */
|
|
111
|
+
private goalCancelled = false
|
|
112
|
+
/** Goal completion mode. "auto": complete when the pipeline drains naturally.
|
|
113
|
+
* "eval": after each drain, route to the evaluator to verify the goal and
|
|
114
|
+
* either dispatch more work or declare GOAL_MET. */
|
|
115
|
+
private goalMode: "auto" | "eval" = "auto"
|
|
116
|
+
/** Agent id that evaluates the goal in "eval" mode. */
|
|
117
|
+
private goalEvaluator = "planner"
|
|
118
|
+
/** Max eval iterations before the goal auto-fails (eval mode only). */
|
|
119
|
+
private maxGoalIterations = 10
|
|
120
|
+
/** Eval iterations consumed so far in the current goal run. */
|
|
121
|
+
private goalIteration = 0
|
|
122
|
+
/** Fallback agent saved while an eval-mode goal suppresses fallback routing.
|
|
123
|
+
* Restored when the eval loop terminates. */
|
|
124
|
+
private goalEvalSavedFallback: string | null = null
|
|
125
|
+
|
|
126
|
+
// ── Current conversation identity ──────────────────────────────────────────
|
|
127
|
+
private convId = newConvId()
|
|
128
|
+
private convTitle = "Discussion 1"
|
|
129
|
+
private convCreatedAt = Date.now()
|
|
130
|
+
private saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
private readonly registry: Registry,
|
|
134
|
+
private readonly hub: SseHub,
|
|
135
|
+
private readonly store: ConversationStore,
|
|
136
|
+
private readonly seedPersonas: Persona[],
|
|
137
|
+
/** Logical room identifier — included in all SSE broadcasts for future room-scoped filtering. */
|
|
138
|
+
readonly roomId: string = "default",
|
|
139
|
+
/** Optional process-global lock for serializing local-model inference across rooms. */
|
|
140
|
+
private readonly localLock?: LocalModelLock,
|
|
141
|
+
/** Whether the repetition/tool-loop circuit breaker is active. Defaults to
|
|
142
|
+
* config.circuitBreaker (ON). Disabled for cloud models that legitimately repeat.
|
|
143
|
+
* Mutable — can be toggled per-room via the Settings panel. */
|
|
144
|
+
private circuitBreakerEnabled: boolean = config.circuitBreaker,
|
|
145
|
+
/** Directory this room is scoped to: where file tools are confined, bash runs,
|
|
146
|
+
* work receipts snapshot, and the workspace listing looks. Defaults to the
|
|
147
|
+
* pipeline workspace. */
|
|
148
|
+
private readonly workspaceDir: string = config.workspaceDir,
|
|
149
|
+
/** True when the workspace is a remote (sshfs) mount. Walking the whole
|
|
150
|
+
* remote tree per turn would stall every action for ~a minute over the
|
|
151
|
+
* network, so work receipts and the live workspace listing are skipped
|
|
152
|
+
* for remote rooms. */
|
|
153
|
+
private readonly remote: boolean = false,
|
|
154
|
+
) {}
|
|
155
|
+
|
|
156
|
+
/** Default thinking level for agents without a per-agent override.
|
|
157
|
+
* Mutable — can be changed per-room via the Settings panel.
|
|
158
|
+
* Propagated to the Registry so new participants use it. */
|
|
159
|
+
private defaultThinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" = config.thinkingLevel
|
|
160
|
+
|
|
161
|
+
/** Whether cloud models are allowed in this room. Mutable — can be toggled
|
|
162
|
+
* per-room via the Settings panel. Propagated to the Registry so new
|
|
163
|
+
* participants use the room's policy. */
|
|
164
|
+
private allowCloud: boolean = config.allowCloud
|
|
165
|
+
|
|
166
|
+
/** Reserve tokens for auto-compaction. Mutable — can be changed per-room via
|
|
167
|
+
* the Settings panel. Propagated to the Registry so new participants use
|
|
168
|
+
* the room's value. */
|
|
169
|
+
private compactionReserveTokens: number = 38000
|
|
170
|
+
|
|
171
|
+
/** Broadcast wrapper: tags object payloads with roomId; arrays pass through unmodified. */
|
|
172
|
+
private emit(event: SseEventName, data: unknown): void {
|
|
173
|
+
const payload =
|
|
174
|
+
data !== null && typeof data === "object" && !Array.isArray(data)
|
|
175
|
+
? { roomId: this.roomId, ...(data as Record<string, unknown>) }
|
|
176
|
+
: data
|
|
177
|
+
this.hub.broadcast(event, payload, this.roomId)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getTranscript(): TranscriptEntry[] {
|
|
181
|
+
return this.transcript
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The directory this room's agents are scoped to (file tools, bash cwd, receipts). */
|
|
185
|
+
getWorkspaceDir(): string {
|
|
186
|
+
return this.workspaceDir
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Workspace file listing for the UI panel. Empty for remote (sshfs) rooms:
|
|
190
|
+
* walking the whole remote tree over the network would take ~a minute. */
|
|
191
|
+
async getWorkspaceListing(): Promise<Array<{ path: string; size: number }>> {
|
|
192
|
+
return this.remote ? [] : listWorkspace(this.workspaceDir)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async emitWorkspace(): Promise<void> {
|
|
196
|
+
this.emit("workspace", await this.getWorkspaceListing())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Number of participants (active + inactive) in this room's registry. */
|
|
200
|
+
rosterLength(): number {
|
|
201
|
+
return this.registry.roster().length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getGoalText(): string | null { return this.goalText }
|
|
205
|
+
getGoalStatus(): "idle" | "running" | "completed" | "failed" | "cancelled" { return this.goalStatus }
|
|
206
|
+
|
|
207
|
+
/** Start a goal-driven pipeline run. Sets goalText/status and fires the first turn.
|
|
208
|
+
* In "eval" mode the evaluator agent verifies the goal after each drain and
|
|
209
|
+
* drives an iterative dispatch loop until it declares GOAL_MET or the iteration
|
|
210
|
+
* budget is exhausted. */
|
|
211
|
+
submitGoal(
|
|
212
|
+
text: string,
|
|
213
|
+
opts?: { mode?: "auto" | "eval"; evaluator?: string; maxIterations?: number },
|
|
214
|
+
): void {
|
|
215
|
+
this.goalText = text
|
|
216
|
+
this.goalStatus = "running"
|
|
217
|
+
this.goalMode = opts?.mode ?? "auto"
|
|
218
|
+
this.goalEvaluator = opts?.evaluator?.trim() || "planner"
|
|
219
|
+
this.maxGoalIterations = Math.max(1, Math.min(50, Math.round(opts?.maxIterations ?? 10)))
|
|
220
|
+
this.goalIteration = 0
|
|
221
|
+
this.goalCancelled = false
|
|
222
|
+
// In eval mode the eval loop is the sole router: the evaluator is invoked
|
|
223
|
+
// deliberately after every natural drain. Leaving generic fallback routing
|
|
224
|
+
// active would re-invoke the evaluator (when it is also the fallback agent)
|
|
225
|
+
// with a misleading "routing fallback" context — doubling invocations and
|
|
226
|
+
// draining the iteration budget. Suppress fallback for the whole goal run
|
|
227
|
+
// (initial drain + eval loop); runGoalEval's finally restores it.
|
|
228
|
+
if (this.goalMode === "eval") {
|
|
229
|
+
this.goalEvalSavedFallback = this.fallbackAgentId
|
|
230
|
+
this.fallbackAgentId = null
|
|
231
|
+
}
|
|
232
|
+
this.submit(text)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getGoalMode(): "auto" | "eval" { return this.goalMode }
|
|
236
|
+
|
|
237
|
+
/** Expose the registry for server-side route handlers. */
|
|
238
|
+
getRegistry(): Registry {
|
|
239
|
+
return this.registry
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
getChaining(): boolean {
|
|
243
|
+
return this.chaining
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setChaining(value: boolean): void {
|
|
247
|
+
this.chaining = value
|
|
248
|
+
this.broadcastSettings()
|
|
249
|
+
void this.saveCurrent()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getRoutingMode(): RoutingMode {
|
|
253
|
+
return this.routingMode
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
setRoutingMode(mode: RoutingMode): void {
|
|
257
|
+
this.routingMode = mode
|
|
258
|
+
this.broadcastSettings()
|
|
259
|
+
void this.saveCurrent()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getDefaultAgent(): string | null {
|
|
263
|
+
return this.defaultAgentId
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Set the agent that handles un-mentioned messages. null = first active. */
|
|
267
|
+
setDefaultAgent(id: string | null): void {
|
|
268
|
+
if (id !== null && !this.registry.has(id)) throw new Error(`unknown participant "${id}"`)
|
|
269
|
+
this.defaultAgentId = id
|
|
270
|
+
this.broadcastSettings()
|
|
271
|
+
void this.saveCurrent()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getFallbackAgent(): string | null {
|
|
275
|
+
return this.fallbackAgentId
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Set the agent that receives routing fallback. null = disabled. */
|
|
279
|
+
setFallbackAgent(id: string | null): void {
|
|
280
|
+
if (id !== null && !this.registry.has(id)) throw new Error(`unknown participant "${id}"`)
|
|
281
|
+
this.fallbackAgentId = id
|
|
282
|
+
this.broadcastSettings()
|
|
283
|
+
void this.saveCurrent()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getMaxChainHops(): number {
|
|
287
|
+
return this.maxChainHops
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setMaxChainHops(n: number): void {
|
|
291
|
+
this.maxChainHops = Math.max(1, Math.min(100, Math.round(n)))
|
|
292
|
+
this.broadcastSettings()
|
|
293
|
+
void this.saveCurrent()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Circuit breaker toggle ──────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
getCircuitBreaker(): boolean {
|
|
299
|
+
return this.circuitBreakerEnabled
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
setCircuitBreaker(enabled: boolean): void {
|
|
303
|
+
this.circuitBreakerEnabled = enabled
|
|
304
|
+
this.broadcastSettings()
|
|
305
|
+
void this.saveCurrent()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Default thinking level ──────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" {
|
|
311
|
+
return this.defaultThinkingLevel
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
|
|
315
|
+
this.defaultThinkingLevel = level
|
|
316
|
+
// Propagate to registry so new participants use the updated default.
|
|
317
|
+
this.registry.setDefaultThinkingLevel(level)
|
|
318
|
+
this.broadcastSettings()
|
|
319
|
+
void this.saveCurrent()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Allow cloud toggle ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
getAllowCloud(): boolean {
|
|
325
|
+
return this.allowCloud
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
setAllowCloud(value: boolean): void {
|
|
329
|
+
this.allowCloud = value
|
|
330
|
+
// Propagate to registry so new participants use the updated policy.
|
|
331
|
+
this.registry.setAllowCloud(value)
|
|
332
|
+
this.broadcastSettings()
|
|
333
|
+
void this.saveCurrent()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Compaction reserve tokens ────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
getCompactionReserveTokens(): number {
|
|
339
|
+
return this.compactionReserveTokens
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setCompactionReserveTokens(value: number): void {
|
|
343
|
+
this.compactionReserveTokens = Math.max(5000, Math.min(100000, Math.round(value)))
|
|
344
|
+
// Propagate to registry so new participants use the updated value.
|
|
345
|
+
this.registry.setCompactionReserveTokens(this.compactionReserveTokens)
|
|
346
|
+
this.broadcastSettings()
|
|
347
|
+
void this.saveCurrent()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private broadcastSettings(): void {
|
|
351
|
+
this.emit("settings", {
|
|
352
|
+
chaining: this.chaining,
|
|
353
|
+
routingMode: this.routingMode,
|
|
354
|
+
defaultAgent: this.defaultAgentId,
|
|
355
|
+
fallbackAgent: this.fallbackAgentId,
|
|
356
|
+
maxChainHops: this.maxChainHops,
|
|
357
|
+
circuitBreaker: this.circuitBreakerEnabled,
|
|
358
|
+
defaultThinkingLevel: this.defaultThinkingLevel,
|
|
359
|
+
allowCloud: this.allowCloud,
|
|
360
|
+
compactionReserveTokens: this.compactionReserveTokens,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Conversation lifecycle ──────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/** Load the most recent saved conversation, or seed a fresh one. Wires autosave. */
|
|
367
|
+
async init(): Promise<void> {
|
|
368
|
+
await this.store.init()
|
|
369
|
+
const metas = await this.store.list()
|
|
370
|
+
const latest = metas[0] ? await this.store.read(metas[0].id) : null
|
|
371
|
+
if (latest) {
|
|
372
|
+
await this.applyConversation(latest)
|
|
373
|
+
} else {
|
|
374
|
+
await this.startFresh(
|
|
375
|
+
"Discussion 1",
|
|
376
|
+
this.seedPersonas.map((p) => ({ ...p, active: true })),
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
// From now on, any roster change autosaves the current conversation.
|
|
380
|
+
this.registry.onChange = () => this.scheduleSave()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private buildConversation(): Conversation {
|
|
384
|
+
return {
|
|
385
|
+
id: this.convId,
|
|
386
|
+
title: this.convTitle,
|
|
387
|
+
createdAt: this.convCreatedAt,
|
|
388
|
+
updatedAt: Date.now(),
|
|
389
|
+
chaining: this.chaining,
|
|
390
|
+
routingMode: this.routingMode,
|
|
391
|
+
defaultAgent: this.defaultAgentId,
|
|
392
|
+
fallbackAgent: this.fallbackAgentId,
|
|
393
|
+
circuitBreaker: this.circuitBreakerEnabled,
|
|
394
|
+
defaultThinkingLevel: this.defaultThinkingLevel,
|
|
395
|
+
allowCloud: this.allowCloud,
|
|
396
|
+
compactionReserveTokens: this.compactionReserveTokens,
|
|
397
|
+
personas: this.registry.personaStates(),
|
|
398
|
+
transcript: this.transcript,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Persist the current conversation and push the refreshed list to clients. */
|
|
403
|
+
async saveCurrent(): Promise<void> {
|
|
404
|
+
if (this.saveTimer) {
|
|
405
|
+
clearTimeout(this.saveTimer)
|
|
406
|
+
this.saveTimer = null
|
|
407
|
+
}
|
|
408
|
+
await this.store.write(this.buildConversation())
|
|
409
|
+
await this.broadcastConversations()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Debounced autosave, for bursty roster mutations. */
|
|
413
|
+
private scheduleSave(): void {
|
|
414
|
+
if (this.saveTimer) clearTimeout(this.saveTimer)
|
|
415
|
+
this.saveTimer = setTimeout(() => {
|
|
416
|
+
this.saveTimer = null
|
|
417
|
+
void this.saveCurrent()
|
|
418
|
+
}, 400)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async getConversations(): Promise<{ currentId: string; list: ConversationMeta[] }> {
|
|
422
|
+
return { currentId: this.convId, list: await this.store.list() }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async broadcastConversations(): Promise<void> {
|
|
426
|
+
this.emit("conversations", {
|
|
427
|
+
currentId: this.convId,
|
|
428
|
+
list: await this.store.list(),
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** True while an agent is running or queued — editing a roster member's
|
|
433
|
+
* session (which disposes+recreates it) is unsafe during this window. */
|
|
434
|
+
isBusy(): boolean {
|
|
435
|
+
return this.running.size > 0 || this.queue.length > 0 || this.pendingQuestion !== null || this.pendingRoute !== null
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private ensureIdle(): void {
|
|
439
|
+
if (this.running.size > 0 || this.queue.length > 0 || this.pendingQuestion !== null || this.pendingRoute !== null) {
|
|
440
|
+
throw new Error("a turn is running — press Stop before switching discussions")
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Become a brand-new empty conversation with the given roster. */
|
|
445
|
+
private async startFresh(title: string, personas: Conversation["personas"]): Promise<void> {
|
|
446
|
+
this.convId = newConvId()
|
|
447
|
+
this.convTitle = title
|
|
448
|
+
this.convCreatedAt = Date.now()
|
|
449
|
+
this.transcript = []
|
|
450
|
+
this.defaultAgentId = null // fresh discussion → first active is the default
|
|
451
|
+
await this.registry.reset(personas)
|
|
452
|
+
this.emit("transcript", this.transcript)
|
|
453
|
+
this.broadcastSettings()
|
|
454
|
+
await this.saveCurrent()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Make a saved conversation the live one (fresh sessions, replayed transcript). */
|
|
458
|
+
private async applyConversation(conv: Conversation): Promise<void> {
|
|
459
|
+
this.convId = conv.id
|
|
460
|
+
this.convTitle = conv.title
|
|
461
|
+
this.convCreatedAt = conv.createdAt
|
|
462
|
+
this.routingMode = conv.routingMode ?? (conv.chaining ? "auto" : "manual")
|
|
463
|
+
this.defaultAgentId = conv.defaultAgent ?? null
|
|
464
|
+
this.fallbackAgentId = conv.fallbackAgent ?? "planner"
|
|
465
|
+
// Back-compat: older saves don't have these fields — fall back to config defaults.
|
|
466
|
+
this.circuitBreakerEnabled = conv.circuitBreaker ?? config.circuitBreaker
|
|
467
|
+
if (conv.defaultThinkingLevel) {
|
|
468
|
+
const level = conv.defaultThinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
|
|
469
|
+
this.defaultThinkingLevel = level
|
|
470
|
+
this.registry.setDefaultThinkingLevel(level)
|
|
471
|
+
}
|
|
472
|
+
// Back-compat: older saves don't have allowCloud — fall back to config default.
|
|
473
|
+
this.allowCloud = conv.allowCloud ?? config.allowCloud
|
|
474
|
+
this.registry.setAllowCloud(this.allowCloud)
|
|
475
|
+
// Back-compat: older saves don't have compactionReserveTokens — fall back to 38000.
|
|
476
|
+
this.compactionReserveTokens = conv.compactionReserveTokens ?? 38000
|
|
477
|
+
this.registry.setCompactionReserveTokens(this.compactionReserveTokens)
|
|
478
|
+
|
|
479
|
+
// Guard against a corrupt save with an empty roster (e.g. a botched
|
|
480
|
+
// out-of-band edit / mid-turn restart). An empty roster would brick the UI
|
|
481
|
+
// permanently — and since init() loads the most recent conversation, it
|
|
482
|
+
// would do so on every boot. Fall back to the seed personas and re-persist.
|
|
483
|
+
let healed = false
|
|
484
|
+
let personas = conv.personas
|
|
485
|
+
if (personas.length === 0) {
|
|
486
|
+
personas = this.seedPersonas.map((p) => ({ ...p, active: true }))
|
|
487
|
+
healed = true
|
|
488
|
+
}
|
|
489
|
+
await this.registry.reset(personas)
|
|
490
|
+
// cursor=0 (set by reset→create) means each agent catches up on the whole
|
|
491
|
+
// transcript on its next turn — its fresh session has no prior memory.
|
|
492
|
+
this.transcript = conv.transcript.map((e) => ({ ...e }))
|
|
493
|
+
this.broadcastSettings()
|
|
494
|
+
this.emit("transcript", this.transcript)
|
|
495
|
+
if (healed) {
|
|
496
|
+
this.notice(`"${conv.title}" had an empty roster — restored the seed agents.`, "info")
|
|
497
|
+
await this.saveCurrent() // make the repair stick on disk
|
|
498
|
+
}
|
|
499
|
+
await this.broadcastConversations()
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Start a new discussion, inheriting the current roster. Returns its metadata. */
|
|
503
|
+
async newConversation(title?: string): Promise<ConversationMeta> {
|
|
504
|
+
this.ensureIdle()
|
|
505
|
+
await this.saveCurrent()
|
|
506
|
+
const personas = this.registry.personaStates()
|
|
507
|
+
const count = (await this.store.list()).length
|
|
508
|
+
await this.startFresh(title?.trim() || `Discussion ${count + 1}`, personas)
|
|
509
|
+
return conversationMeta(this.buildConversation())
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Start a new discussion with a preset roster. Returns its metadata. */
|
|
513
|
+
async loadPreset(personas: Conversation["personas"], title?: string): Promise<ConversationMeta> {
|
|
514
|
+
this.ensureIdle()
|
|
515
|
+
await this.saveCurrent()
|
|
516
|
+
const count = (await this.store.list()).length
|
|
517
|
+
await this.startFresh(title?.trim() || `Discussion ${count + 1}`, personas)
|
|
518
|
+
return conversationMeta(this.buildConversation())
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Apply a preset roster to the current room — replaces agents in-place without
|
|
522
|
+
* changing the conversation id, title, or transcript. Persists immediately so
|
|
523
|
+
* the roster survives reboot. */
|
|
524
|
+
async applyPreset(personas: Conversation["personas"]): Promise<ConversationMeta> {
|
|
525
|
+
this.ensureIdle()
|
|
526
|
+
await this.registry.reset(personas)
|
|
527
|
+
this.broadcastSettings()
|
|
528
|
+
await this.saveCurrent()
|
|
529
|
+
await this.broadcastConversations()
|
|
530
|
+
return conversationMeta(this.buildConversation())
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Switch to a saved discussion. No-op if already current. */
|
|
534
|
+
async switchConversation(id: string): Promise<void> {
|
|
535
|
+
this.ensureIdle()
|
|
536
|
+
if (id === this.convId) return
|
|
537
|
+
const conv = await this.store.read(id)
|
|
538
|
+
if (!conv) throw new Error(`unknown conversation "${id}"`)
|
|
539
|
+
await this.saveCurrent() // flush the one we're leaving
|
|
540
|
+
await this.applyConversation(conv)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async renameConversation(id: string, title: string): Promise<void> {
|
|
544
|
+
const clean = title.trim()
|
|
545
|
+
if (!clean) throw new Error("title is required")
|
|
546
|
+
if (id === this.convId) {
|
|
547
|
+
this.convTitle = clean
|
|
548
|
+
await this.saveCurrent()
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
const conv = await this.store.read(id)
|
|
552
|
+
if (!conv) throw new Error(`unknown conversation "${id}"`)
|
|
553
|
+
conv.title = clean
|
|
554
|
+
conv.updatedAt = Date.now()
|
|
555
|
+
await this.store.write(conv)
|
|
556
|
+
await this.broadcastConversations()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async deleteConversation(id: string): Promise<void> {
|
|
560
|
+
this.ensureIdle()
|
|
561
|
+
await this.store.remove(id)
|
|
562
|
+
if (id === this.convId) {
|
|
563
|
+
// Deleted the live one: fall back to the most recent remaining, else seed.
|
|
564
|
+
const metas = await this.store.list()
|
|
565
|
+
const next = metas[0] ? await this.store.read(metas[0].id) : null
|
|
566
|
+
if (next) await this.applyConversation(next)
|
|
567
|
+
else
|
|
568
|
+
await this.startFresh(
|
|
569
|
+
"Discussion 1",
|
|
570
|
+
this.seedPersonas.map((p) => ({ ...p, active: true })),
|
|
571
|
+
)
|
|
572
|
+
} else {
|
|
573
|
+
await this.broadcastConversations()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private post(
|
|
578
|
+
author: string,
|
|
579
|
+
authorName: string,
|
|
580
|
+
text: string,
|
|
581
|
+
activity?: ToolActivity[],
|
|
582
|
+
reasoning?: string,
|
|
583
|
+
images?: string[],
|
|
584
|
+
question?: string,
|
|
585
|
+
): TranscriptEntry {
|
|
586
|
+
const entry: TranscriptEntry = {
|
|
587
|
+
index: this.transcript.length,
|
|
588
|
+
author,
|
|
589
|
+
authorName,
|
|
590
|
+
text,
|
|
591
|
+
ts: Date.now(),
|
|
592
|
+
...(activity && activity.length > 0 ? { activity } : {}),
|
|
593
|
+
...(reasoning ? { reasoning } : {}),
|
|
594
|
+
...(images && images.length > 0 ? { images } : {}),
|
|
595
|
+
...(question ? { question } : {}),
|
|
596
|
+
}
|
|
597
|
+
this.transcript.push(entry)
|
|
598
|
+
|
|
599
|
+
// Circuit breaker — only for agents, not user
|
|
600
|
+
if (this.circuitBreakerEnabled && author !== "user" && this.checkRepetition(author, text)) {
|
|
601
|
+
this.aborted = true
|
|
602
|
+
this.circuitBreakerAgentId = author
|
|
603
|
+
const msg = `Circuit breaker: @${authorName} repeated similar output ${REPEAT_THRESHOLD} times — stopping.`
|
|
604
|
+
this.notice(msg, "error")
|
|
605
|
+
this.emit("circuit_breaker", { agentId: author, agentName: authorName, count: REPEAT_THRESHOLD })
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Tool-call loop breaker — detect repeated identical tool calls
|
|
609
|
+
if (this.circuitBreakerEnabled && author !== "user" && activity && activity.length > 0 && !this.aborted) {
|
|
610
|
+
const result = checkToolLoop(this.transcript, author, activity)
|
|
611
|
+
if (result.tripped) {
|
|
612
|
+
this.aborted = true
|
|
613
|
+
this.circuitBreakerAgentId = author
|
|
614
|
+
const sig = result.signature ?? "unknown"
|
|
615
|
+
const msg = `Circuit breaker: @${authorName} repeated tool call "${sig}" ${result.count} times — stopping.`
|
|
616
|
+
this.notice(msg, "error")
|
|
617
|
+
this.emit("circuit_breaker", { agentId: author, agentName: authorName, count: result.count, type: "tool_loop", signature: sig })
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this.emit("message", entry)
|
|
622
|
+
return entry
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Check if the current text is a repetition of recent messages from the same author.
|
|
627
|
+
* Scans the last LOOKBACK_WINDOW messages from that author; if ≥ REPEAT_THRESHOLD
|
|
628
|
+
* have similarity ≥ SIMILARITY_FLOOR, returns true.
|
|
629
|
+
*/
|
|
630
|
+
private checkRepetition(author: string, text: string): boolean {
|
|
631
|
+
const recent: string[] = []
|
|
632
|
+
for (let i = this.transcript.length - 1; i >= 0; i--) {
|
|
633
|
+
const entry = this.transcript[i]
|
|
634
|
+
if (entry.author !== author) continue
|
|
635
|
+
recent.push(entry.text)
|
|
636
|
+
if (recent.length >= LOOKBACK_WINDOW) break
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let similarCount = 0
|
|
640
|
+
for (const prev of recent) {
|
|
641
|
+
if (textSimilarity(text, prev) >= SIMILARITY_FLOOR) {
|
|
642
|
+
similarCount++
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// similarCount includes the current message's match against itself,
|
|
647
|
+
// so we need ≥ REPEAT_THRESHOLD total (the current message + REPEAT_THRESHOLD-1 prior)
|
|
648
|
+
return similarCount >= REPEAT_THRESHOLD
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private notice(msg: string, level: "info" | "error" = "info"): void {
|
|
652
|
+
this.emit("notice", { msg, level })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** End the current turn — clears runningAgentId and broadcasts turn end. */
|
|
656
|
+
private async endTurn(): Promise<void> {
|
|
657
|
+
this.runningAgentId = null
|
|
658
|
+
// Natural turn completion: if a goal was running, resolve it.
|
|
659
|
+
if (this.goalText !== null && this.goalStatus === "running") {
|
|
660
|
+
if (this.goalMode === "eval") {
|
|
661
|
+
// Don't auto-complete — hand off to the evaluator to verify the goal
|
|
662
|
+
// and drive the dispatch loop. runGoalEval sets the terminal status.
|
|
663
|
+
await this.runGoalEval()
|
|
664
|
+
} else {
|
|
665
|
+
this.goalStatus = "completed"
|
|
666
|
+
this.emit("room", { type: "goal-completed", goalText: this.goalText })
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
this.emit("turn", { phase: "end" })
|
|
670
|
+
await this.emitWorkspace()
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Matches the GOAL_MET completion token in any reasonable form. */
|
|
674
|
+
private static readonly GOAL_MET_RE = /\bGOAL[\s_-]?MET\b/i
|
|
675
|
+
|
|
676
|
+
/** Goal-eval loop (eval mode). After the pipeline drains naturally, route to
|
|
677
|
+
* the evaluator agent with a structured prompt. The evaluator verifies the
|
|
678
|
+
* goal independently (using its tools), then either:
|
|
679
|
+
* - declares GOAL_MET → goal completes, loop exits; or
|
|
680
|
+
* - @-mentions an agent → that agent runs (via drainQueue chaining), then
|
|
681
|
+
* the loop re-evaluates.
|
|
682
|
+
* Bounded by maxGoalIterations to guarantee termination. Called from within
|
|
683
|
+
* endTurn(); it drives drainQueue() directly and never re-enters endTurn(),
|
|
684
|
+
* so there is no recursion. */
|
|
685
|
+
private async runGoalEval(): Promise<void> {
|
|
686
|
+
const evaluator = this.registry.get(this.goalEvaluator)
|
|
687
|
+
if (!evaluator || !evaluator.active) {
|
|
688
|
+
// No evaluator available — fall back to auto-completion rather than hang
|
|
689
|
+
// the goal in "running" forever.
|
|
690
|
+
this.notice(`Goal eval: evaluator @${this.goalEvaluator} not available — completing goal without verification.`, "info")
|
|
691
|
+
this.goalStatus = "completed"
|
|
692
|
+
this.emit("room", { type: "goal-completed", goalText: this.goalText })
|
|
693
|
+
this.fallbackAgentId = this.goalEvalSavedFallback
|
|
694
|
+
this.runningAgentId = null
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Fallback routing is already suppressed (set null in submitGoal for the
|
|
699
|
+
// whole eval-mode run). The finally restores the original fallback agent and
|
|
700
|
+
// re-nulls runningAgentId per endTurn's documented contract.
|
|
701
|
+
try {
|
|
702
|
+
while (this.goalIteration < this.maxGoalIterations) {
|
|
703
|
+
// Cancellation (abortCurrent / stop_room / Stop button) wins over
|
|
704
|
+
// everything: end the goal as "cancelled" without another pass. Checked
|
|
705
|
+
// here (between iterations) and again after the drain below.
|
|
706
|
+
if (this.goalCancelled) {
|
|
707
|
+
this.goalStatus = "cancelled"
|
|
708
|
+
this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
|
|
709
|
+
this.notice(`Goal cancelled on iteration ${this.goalIteration}.`, "info")
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
this.goalIteration++
|
|
713
|
+
|
|
714
|
+
// Inject the structured eval context (invisible in the transcript).
|
|
715
|
+
await evaluator.sendCustomMessage(
|
|
716
|
+
{
|
|
717
|
+
customType: "goal_eval",
|
|
718
|
+
content: goalEvalPrompt(this.goalText!, this.goalIteration, this.maxGoalIterations),
|
|
719
|
+
display: false,
|
|
720
|
+
},
|
|
721
|
+
{ deliverAs: "nextTurn" },
|
|
722
|
+
)
|
|
723
|
+
this.emit("room", {
|
|
724
|
+
type: "goal-eval",
|
|
725
|
+
goalText: this.goalText,
|
|
726
|
+
iteration: this.goalIteration,
|
|
727
|
+
maxIterations: this.maxGoalIterations,
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// Run the evaluator and any agents it dispatches via @mention chaining.
|
|
731
|
+
this.queue = [evaluator]
|
|
732
|
+
this.aborted = false
|
|
733
|
+
this.circuitBreakerAgentId = null
|
|
734
|
+
this.chainBudget = 0
|
|
735
|
+
this.runningAgentId = evaluator.persona.id
|
|
736
|
+
this.emit("turn", { phase: "chain", from: null, targets: [evaluator.persona.id] })
|
|
737
|
+
await this.drainQueue()
|
|
738
|
+
|
|
739
|
+
// Cancelled mid-drain — stop now, before reinterpreting the drain as a
|
|
740
|
+
// completion or circuit-breaker failure.
|
|
741
|
+
if (this.goalCancelled) {
|
|
742
|
+
this.goalStatus = "cancelled"
|
|
743
|
+
this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
|
|
744
|
+
this.notice(`Goal cancelled on iteration ${this.goalIteration}.`, "info")
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Did the evaluator declare the goal met in its most recent message?
|
|
749
|
+
if (this.evaluatorDeclaredGoalMet(evaluator.persona.id)) {
|
|
750
|
+
this.goalStatus = "completed"
|
|
751
|
+
this.emit("room", { type: "goal-completed", goalText: this.goalText })
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Circuit breaker tripped during this eval pass — give up.
|
|
756
|
+
if (this.aborted) {
|
|
757
|
+
this.goalStatus = "failed"
|
|
758
|
+
this.emit("room", { type: "goal-failed", goalText: this.goalText, reason: "aborted" })
|
|
759
|
+
this.notice(`Goal eval aborted on iteration ${this.goalIteration} (circuit breaker).`, "error")
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Iteration budget exhausted without GOAL_MET.
|
|
765
|
+
this.goalStatus = "failed"
|
|
766
|
+
this.emit("room", { type: "goal-failed", goalText: this.goalText, reason: "max-iterations" })
|
|
767
|
+
this.notice(`Goal eval exhausted after ${this.maxGoalIterations} iterations without GOAL_MET.`, "error")
|
|
768
|
+
} finally {
|
|
769
|
+
this.fallbackAgentId = this.goalEvalSavedFallback
|
|
770
|
+
this.runningAgentId = null
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** True if the evaluator's most recent transcript message declares GOAL_MET. */
|
|
775
|
+
private evaluatorDeclaredGoalMet(evaluatorId: string): boolean {
|
|
776
|
+
for (let i = this.transcript.length - 1; i >= 0; i--) {
|
|
777
|
+
const e = this.transcript[i]
|
|
778
|
+
if (e.author === evaluatorId) return Room.GOAL_MET_RE.test(e.text)
|
|
779
|
+
}
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Steer a running agent mid-turn. Posts a (steered) notice to the transcript
|
|
784
|
+
* for visibility, then queues the message via the agent's session. */
|
|
785
|
+
async steer(targetId: string, text: string): Promise<void> {
|
|
786
|
+
const p = this.registry.get(targetId)
|
|
787
|
+
if (!p) throw new Error(`unknown participant "${targetId}"`)
|
|
788
|
+
// Post a visible record to the transcript.
|
|
789
|
+
this.post("user", "You", `↳ steered @${targetId}: ${text}`)
|
|
790
|
+
await p.steer(text)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Parse @mentions and resolve to the ordered list of participants to run. */
|
|
794
|
+
private resolveTargets(text: string): Participant[] {
|
|
795
|
+
const mentioned = new Set<string>()
|
|
796
|
+
let m: RegExpExecArray | null
|
|
797
|
+
while ((m = MENTION_RE.exec(text)) !== null) mentioned.add(m[1].toLowerCase())
|
|
798
|
+
|
|
799
|
+
// @all (human-only) fans out to everyone, even alongside other mentions.
|
|
800
|
+
if (mentioned.has("all")) {
|
|
801
|
+
return this.registry.activeParticipants()
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// No mention → the default agent (or the first active one as fallback).
|
|
805
|
+
if (mentioned.size === 0) {
|
|
806
|
+
const active = this.registry.activeParticipants()
|
|
807
|
+
if (active.length === 0) return []
|
|
808
|
+
const preferred = this.defaultAgentId
|
|
809
|
+
? active.find((p) => p.persona.id === this.defaultAgentId)
|
|
810
|
+
: undefined
|
|
811
|
+
return [preferred ?? active[0]]
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const targets: Participant[] = []
|
|
815
|
+
for (const id of mentioned) {
|
|
816
|
+
const p = this.registry.get(id)
|
|
817
|
+
if (!p) {
|
|
818
|
+
this.notice(`No participant "@${id}" in the room.`, "error")
|
|
819
|
+
continue
|
|
820
|
+
}
|
|
821
|
+
if (!p.active) {
|
|
822
|
+
this.notice(`@${id} is deactivated — skipping.`, "info")
|
|
823
|
+
continue
|
|
824
|
+
}
|
|
825
|
+
targets.push(p)
|
|
826
|
+
}
|
|
827
|
+
return targets
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Resolve @mentions emitted BY an agent. No @all (human-only), never the
|
|
831
|
+
* speaker itself, active participants only. Scans the full reply text.
|
|
832
|
+
* No budget / anti-rebound for now. */
|
|
833
|
+
private resolveAgentMentions(text: string, selfId: string): Participant[] {
|
|
834
|
+
const mentioned = new Set<string>()
|
|
835
|
+
let m: RegExpExecArray | null
|
|
836
|
+
while ((m = MENTION_RE.exec(text)) !== null) mentioned.add(m[1].toLowerCase())
|
|
837
|
+
mentioned.delete("all") // agents cannot fan out to everyone
|
|
838
|
+
mentioned.delete(selfId) // no self-invocation
|
|
839
|
+
|
|
840
|
+
const out: Participant[] = []
|
|
841
|
+
for (const id of mentioned) {
|
|
842
|
+
const p = this.registry.get(id)
|
|
843
|
+
if (p && p.active) out.push(p)
|
|
844
|
+
}
|
|
845
|
+
return out
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Build the prompt for a participant: the room lines it hasn't seen yet
|
|
849
|
+
* (excluding its own past messages, which live in its session memory).
|
|
850
|
+
* Also collects images from the last user message for vision support. */
|
|
851
|
+
private buildContext(p: Participant): { text: string; images?: string[] } {
|
|
852
|
+
const unseen = this.transcript
|
|
853
|
+
.slice(p.cursor)
|
|
854
|
+
.filter((e) => e.author !== p.persona.id)
|
|
855
|
+
const lines = unseen.map((e) => `${e.authorName}: ${e.text}`).join("\n\n")
|
|
856
|
+
|
|
857
|
+
// Collect images from the last user message in the unseen range.
|
|
858
|
+
// These are the images the user attached to their most recent message.
|
|
859
|
+
const userEntry = [...unseen].reverse().find((e) => e.author === "user")
|
|
860
|
+
const images = userEntry?.images
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
text: `${lines}\n\n---\nYou are ${p.persona.name}. Respond to the conversation above from your perspective now.`,
|
|
864
|
+
images,
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Public entry point. Enqueues the message; processing streams over SSE. */
|
|
869
|
+
submit(text: string, images?: string[]): void {
|
|
870
|
+
this.chain = this.chain.then(() => this.process(text, images)).catch((err) => {
|
|
871
|
+
this.notice(`Room error: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
872
|
+
})
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async process(text: string, images?: string[]): Promise<void> {
|
|
876
|
+
const trimmed = text.trim()
|
|
877
|
+
this.chainBudget = 0 // reset chain budget for this turn
|
|
878
|
+
|
|
879
|
+
// Handle /cancel while paused — cancel the question and drain the held queue.
|
|
880
|
+
if (this.pendingQuestion && trimmed === "/cancel") {
|
|
881
|
+
const held = this.pendingQuestion.heldQueue
|
|
882
|
+
this.pendingQuestion = null
|
|
883
|
+
this.post("user", "You", trimmed)
|
|
884
|
+
this.notice("Question cancelled. Resuming pipeline.")
|
|
885
|
+
this.queue = held
|
|
886
|
+
this.aborted = false
|
|
887
|
+
const paused = await this.drainQueue()
|
|
888
|
+
if (paused) {
|
|
889
|
+
await this.emitWorkspace()
|
|
890
|
+
await this.saveCurrent()
|
|
891
|
+
return
|
|
892
|
+
}
|
|
893
|
+
this.queue = []
|
|
894
|
+
await this.endTurn()
|
|
895
|
+
await this.saveCurrent()
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (await this.handleSlashCommand(trimmed)) return
|
|
900
|
+
|
|
901
|
+
// ── Resume from paused state ──────────────────────────────────────────
|
|
902
|
+
if (this.pendingQuestion) {
|
|
903
|
+
const pq = this.pendingQuestion
|
|
904
|
+
this.pendingQuestion = null
|
|
905
|
+
|
|
906
|
+
this.post("user", "You", trimmed, undefined, undefined, images)
|
|
907
|
+
this.emit("turn", { phase: "resume", askerId: pq.askerId })
|
|
908
|
+
this.aborted = false
|
|
909
|
+
|
|
910
|
+
// Force-route to the agent that asked the question.
|
|
911
|
+
const asker = this.registry.get(pq.askerId)
|
|
912
|
+
if (!asker || !asker.active) {
|
|
913
|
+
this.notice(`@${pq.askerId} is no longer active — resuming held queue.`, "info")
|
|
914
|
+
this.queue = pq.heldQueue
|
|
915
|
+
const paused = await this.drainQueue()
|
|
916
|
+
if (paused) {
|
|
917
|
+
await this.emitWorkspace()
|
|
918
|
+
await this.saveCurrent()
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
this.queue = []
|
|
922
|
+
await this.endTurn()
|
|
923
|
+
await this.saveCurrent()
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Use followUp() instead of runAgent() — the user's answer is delivered
|
|
928
|
+
// directly to the agent that asked the question. The agent already has the
|
|
929
|
+
// conversation context in its session memory; followUp() guarantees it's
|
|
930
|
+
// the next thing the agent processes.
|
|
931
|
+
const result = await this.followUpAgent(asker, { text: trimmed, images })
|
|
932
|
+
if (result && !this.aborted) {
|
|
933
|
+
// Post with question field if the asker asked another question.
|
|
934
|
+
this.post(asker.persona.id, asker.persona.name, result.reply || "(no response)", result.activity, result.reasoning, undefined, result.question)
|
|
935
|
+
if (receiptHasChanges(result.receipt)) this.emit("receipt", result.receipt)
|
|
936
|
+
asker.cursor = this.transcript.length
|
|
937
|
+
|
|
938
|
+
// If the asker asked ANOTHER question, re-pause.
|
|
939
|
+
if (result.question) {
|
|
940
|
+
this.pendingQuestion = { askerId: asker.persona.id, heldQueue: pq.heldQueue }
|
|
941
|
+
this.emit("turn", { phase: "pause", askerId: asker.persona.id, question: result.question })
|
|
942
|
+
await this.emitWorkspace()
|
|
943
|
+
await this.saveCurrent()
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Chain from the asker's reply onto the held queue (it resumes draining
|
|
948
|
+
// below). Routes identically to the main drain loop — a handoff made right
|
|
949
|
+
// after answering a question now continues instead of being dropped.
|
|
950
|
+
if (this.chaining) {
|
|
951
|
+
const proposed = await this.proposeChain(asker.persona.id, result.reply, pq.heldQueue)
|
|
952
|
+
if (proposed.length > 0) {
|
|
953
|
+
// semi/manual: pause for approval instead of continuing the drain.
|
|
954
|
+
this.pendingRoute = {
|
|
955
|
+
proposals: proposed.map((t) => ({ fromId: asker.persona.id, target: t })),
|
|
956
|
+
heldQueue: pq.heldQueue,
|
|
957
|
+
}
|
|
958
|
+
this.emitRoutingProposed()
|
|
959
|
+
await this.emitWorkspace()
|
|
960
|
+
await this.saveCurrent()
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Restore the held queue and continue draining.
|
|
967
|
+
this.queue = pq.heldQueue
|
|
968
|
+
const paused = await this.drainQueue()
|
|
969
|
+
if (paused) {
|
|
970
|
+
await this.emitWorkspace()
|
|
971
|
+
await this.saveCurrent()
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
this.queue = []
|
|
975
|
+
await this.endTurn()
|
|
976
|
+
await this.saveCurrent()
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ── Normal (non-paused) flow ──────────────────────────────────────────
|
|
981
|
+
this.post("user", "You", trimmed, undefined, undefined, images)
|
|
982
|
+
|
|
983
|
+
const initial = this.resolveTargets(trimmed)
|
|
984
|
+
if (initial.length === 0) {
|
|
985
|
+
this.notice("No active participants to route to.", "info")
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
this.queue = [...initial]
|
|
990
|
+
this.aborted = false
|
|
991
|
+
this.circuitBreakerAgentId = null
|
|
992
|
+
this.runningAgentId = initial[0]?.persona.id ?? null
|
|
993
|
+
this.emit("turn", { phase: "start", targets: initial.map((t) => t.persona.id), agentId: this.runningAgentId })
|
|
994
|
+
|
|
995
|
+
// Drain the queue — shared method handles questions, chaining, and parallel waves.
|
|
996
|
+
const paused = await this.drainQueue()
|
|
997
|
+
if (paused) {
|
|
998
|
+
await this.emitWorkspace()
|
|
999
|
+
await this.saveCurrent()
|
|
1000
|
+
return
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Circuit breaker recovery: if the breaker tripped and a fallback agent
|
|
1004
|
+
// is configured (and is not the looping agent), route to it instead of
|
|
1005
|
+
// silently dying. Loop up to MAX_RECOVERY_DEPTH times to handle cases where
|
|
1006
|
+
// the looping agent re-enters after the fallback hands back to it.
|
|
1007
|
+
const MAX_RECOVERY_DEPTH = 2
|
|
1008
|
+
let recoveryDepth = 0
|
|
1009
|
+
while (
|
|
1010
|
+
this.aborted &&
|
|
1011
|
+
this.circuitBreakerAgentId &&
|
|
1012
|
+
this.fallbackAgentId &&
|
|
1013
|
+
this.circuitBreakerAgentId !== this.fallbackAgentId &&
|
|
1014
|
+
recoveryDepth < MAX_RECOVERY_DEPTH
|
|
1015
|
+
) {
|
|
1016
|
+
recoveryDepth++
|
|
1017
|
+
const fb = this.registry.get(this.fallbackAgentId)
|
|
1018
|
+
if (!fb || !fb.active) break
|
|
1019
|
+
const depthNote = MAX_RECOVERY_DEPTH > 1 ? ` (recovery ${recoveryDepth}/${MAX_RECOVERY_DEPTH})` : ""
|
|
1020
|
+
this.notice(`Circuit breaker tripped on @${this.circuitBreakerAgentId} — routing to @${this.fallbackAgentId} for recovery${depthNote}.`, "info")
|
|
1021
|
+
this.emit("turn", { phase: "chain", from: this.circuitBreakerAgentId, targets: [this.fallbackAgentId] })
|
|
1022
|
+
// Inject recovery context so the fallback agent knows why it's being called.
|
|
1023
|
+
await fb.sendCustomMessage(
|
|
1024
|
+
{
|
|
1025
|
+
customType: "circuit_breaker_recovery",
|
|
1026
|
+
content: `(Circuit breaker tripped on @${this.circuitBreakerAgentId} — it looped. Take over: assess the situation, assign work to another agent by @-mentioning them, or declare the work done if appropriate. Do not attempt the same action that caused the loop.)`,
|
|
1027
|
+
display: false,
|
|
1028
|
+
},
|
|
1029
|
+
{ deliverAs: "nextTurn" },
|
|
1030
|
+
)
|
|
1031
|
+
// Reset abort state and resume draining with the fallback agent.
|
|
1032
|
+
this.aborted = false
|
|
1033
|
+
this.circuitBreakerAgentId = null
|
|
1034
|
+
this.queue = [fb]
|
|
1035
|
+
this.runningAgentId = fb.persona.id
|
|
1036
|
+
const recovered = await this.drainQueue()
|
|
1037
|
+
if (recovered) {
|
|
1038
|
+
await this.emitWorkspace()
|
|
1039
|
+
await this.saveCurrent()
|
|
1040
|
+
return
|
|
1041
|
+
}
|
|
1042
|
+
// If drainQueue exited without pause, loop to check if another circuit breaker fired.
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Goal terminated by abort without recovery. Set before endTurn so its
|
|
1046
|
+
// completion guard doesn't overwrite the status. A user/planner cancel
|
|
1047
|
+
// (goalCancelled) resolves to "cancelled"; a circuit-breaker abort to
|
|
1048
|
+
// "failed".
|
|
1049
|
+
if (this.aborted && this.goalText !== null && this.goalStatus === "running") {
|
|
1050
|
+
if (this.goalCancelled) {
|
|
1051
|
+
this.goalStatus = "cancelled"
|
|
1052
|
+
this.emit("room", { type: "goal-cancelled", goalText: this.goalText })
|
|
1053
|
+
} else {
|
|
1054
|
+
this.goalStatus = "failed"
|
|
1055
|
+
this.emit("room", { type: "goal-failed", goalText: this.goalText })
|
|
1056
|
+
}
|
|
1057
|
+
// Aborting during the INITIAL drain of an eval-mode goal means runGoalEval
|
|
1058
|
+
// never ran, so its finally never restored the fallback agent that
|
|
1059
|
+
// submitGoal suppressed. Restore it here so the room isn't left with
|
|
1060
|
+
// fallback routing silently disabled.
|
|
1061
|
+
if (this.goalMode === "eval") this.fallbackAgentId = this.goalEvalSavedFallback
|
|
1062
|
+
}
|
|
1063
|
+
this.queue = []
|
|
1064
|
+
await this.endTurn()
|
|
1065
|
+
await this.saveCurrent()
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Pull the next group off the queue: a contiguous run of parallel-flagged
|
|
1069
|
+
* agents (a concurrent wave), or a single non-parallel agent (serial). */
|
|
1070
|
+
private nextGroup(): Participant[] {
|
|
1071
|
+
const first = this.queue.shift()!
|
|
1072
|
+
const group = [first]
|
|
1073
|
+
if (first.parallel) {
|
|
1074
|
+
while (this.queue.length > 0 && this.queue[0].parallel) group.push(this.queue.shift()!)
|
|
1075
|
+
}
|
|
1076
|
+
return group
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** The concurrency lane an agent runs on. Same-lane agents are serialized;
|
|
1080
|
+
* different lanes run concurrently. All local models share one lane because
|
|
1081
|
+
* llama-server runs --parallel 1; each cloud provider is its own lane. */
|
|
1082
|
+
private laneOf(p: Participant): string {
|
|
1083
|
+
const provider = p.persona.model ? p.persona.model.split("/")[0] : "local"
|
|
1084
|
+
return provider === "local" ? "local" : `cloud:${provider}`
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Run a group concurrently. All members see the same pre-wave transcript.
|
|
1088
|
+
* Per-lane serialization keeps local agents one-at-a-time; cloud agents on
|
|
1089
|
+
* distinct endpoints run truly in parallel. Results come back in group order. */
|
|
1090
|
+
private async runWave(group: Participant[]): Promise<Array<RunOutput | null>> {
|
|
1091
|
+
const contexts = new Map(group.map((p) => [p, this.buildContext(p)]))
|
|
1092
|
+
const laneTail = new Map<string, Promise<unknown>>()
|
|
1093
|
+
|
|
1094
|
+
return Promise.all(
|
|
1095
|
+
group.map((p) => {
|
|
1096
|
+
const ctx = contexts.get(p) ?? { text: "" }
|
|
1097
|
+
const task = () => this.runAgent(p, ctx)
|
|
1098
|
+
const lane = this.laneOf(p)
|
|
1099
|
+
// Local lane is single-slot: chain tasks so they never overlap.
|
|
1100
|
+
if (lane === "local") {
|
|
1101
|
+
const prev = laneTail.get(lane) ?? Promise.resolve()
|
|
1102
|
+
const result = prev.then(task)
|
|
1103
|
+
laneTail.set(lane, result.catch(() => {}))
|
|
1104
|
+
return result
|
|
1105
|
+
}
|
|
1106
|
+
// Cloud lanes: independent endpoints, run immediately/concurrently.
|
|
1107
|
+
return task()
|
|
1108
|
+
}),
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** Execute one agent end to end: snapshot, prompt/followUp, snapshot, diff.
|
|
1113
|
+
* Does NOT post — the caller posts results in group order to keep the
|
|
1114
|
+
* transcript deterministic. */
|
|
1115
|
+
private async executeAgent(
|
|
1116
|
+
target: Participant,
|
|
1117
|
+
context: { text: string; images?: string[] },
|
|
1118
|
+
mode: "prompt" | "followUp",
|
|
1119
|
+
): Promise<RunOutput | null> {
|
|
1120
|
+
// Remote rooms skip the full-tree diff (too slow over sshfs) — the receipt is
|
|
1121
|
+
// rebuilt from the agent's write/edit tool calls below instead.
|
|
1122
|
+
const before = this.remote ? undefined : await snapshot(this.workspaceDir)
|
|
1123
|
+
this.running.add(target)
|
|
1124
|
+
// Acquire the local-model lock only for local agents (cloud agents bypass).
|
|
1125
|
+
const isLocal = this.laneOf(target) === "local"
|
|
1126
|
+
let lockAcquired = false
|
|
1127
|
+
try {
|
|
1128
|
+
if (isLocal && this.localLock) {
|
|
1129
|
+
await this.localLock.acquire()
|
|
1130
|
+
lockAcquired = true
|
|
1131
|
+
}
|
|
1132
|
+
const result = mode === "prompt"
|
|
1133
|
+
? await target.run(context.text, context.images)
|
|
1134
|
+
: await target.followUp(context.text, context.images)
|
|
1135
|
+
if (this.aborted) return null
|
|
1136
|
+
const after = this.remote ? undefined : await snapshot(this.workspaceDir)
|
|
1137
|
+
|
|
1138
|
+
// Broadcast context usage and session stats after the turn — piggyback on status event.
|
|
1139
|
+
// The idle status already fires from Participant.run() finally block;
|
|
1140
|
+
// this second broadcast adds contextUsage and sessionStats to the payload.
|
|
1141
|
+
const usage = target.getContextUsage?.()
|
|
1142
|
+
const stats = target.getSessionStats?.()
|
|
1143
|
+
if (usage || stats) {
|
|
1144
|
+
const payload: Record<string, unknown> = { id: target.persona.id, status: "idle" }
|
|
1145
|
+
if (usage) payload.contextUsage = usage
|
|
1146
|
+
if (stats) payload.sessionStats = stats
|
|
1147
|
+
this.emit("status", payload)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
target,
|
|
1152
|
+
reply: result.text,
|
|
1153
|
+
activity: result.activity,
|
|
1154
|
+
reasoning: result.reasoning,
|
|
1155
|
+
question: result.question,
|
|
1156
|
+
receipt: before && after
|
|
1157
|
+
? diffSnapshots(before, after, target.persona.id)
|
|
1158
|
+
: receiptFromActivity(result.activity, target.persona.id),
|
|
1159
|
+
}
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
this.notice(
|
|
1162
|
+
`@${target.persona.id} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1163
|
+
"error",
|
|
1164
|
+
)
|
|
1165
|
+
target.cursor = this.transcript.length
|
|
1166
|
+
return null
|
|
1167
|
+
} finally {
|
|
1168
|
+
if (lockAcquired) this.localLock?.release()
|
|
1169
|
+
this.running.delete(target)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Run one agent via prompt. Thin wrapper around executeAgent. */
|
|
1174
|
+
private runAgent(
|
|
1175
|
+
target: Participant,
|
|
1176
|
+
context: { text: string; images?: string[] },
|
|
1177
|
+
): Promise<RunOutput | null> {
|
|
1178
|
+
return this.executeAgent(target, context, "prompt")
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Follow-up one agent via session.followUp(). Thin wrapper around executeAgent.
|
|
1182
|
+
* Used for self-chaining (ask_user resume) — guaranteed to be the next thing
|
|
1183
|
+
* the agent processes. */
|
|
1184
|
+
private followUpAgent(
|
|
1185
|
+
target: Participant,
|
|
1186
|
+
context: { text: string; images?: string[] },
|
|
1187
|
+
): Promise<RunOutput | null> {
|
|
1188
|
+
return this.executeAgent(target, context, "followUp")
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Handle slash commands. Returns true if handled. */
|
|
1192
|
+
private async handleSlashCommand(text: string): Promise<boolean> {
|
|
1193
|
+
if (!text.startsWith("/")) return false
|
|
1194
|
+
const [cmd, ...args] = text.split(/\s+/)
|
|
1195
|
+
const rawTarget = args[0]
|
|
1196
|
+
const id = rawTarget?.replace(/^@/, "").toLowerCase()
|
|
1197
|
+
|
|
1198
|
+
switch (cmd) {
|
|
1199
|
+
case "/kick":
|
|
1200
|
+
if (id && this.registry.has(id)) {
|
|
1201
|
+
this.registry.kick(id)
|
|
1202
|
+
this.notice(`Kicked @${id}.`)
|
|
1203
|
+
} else this.notice(`/kick: unknown participant "${rawTarget ?? ""}".`, "error")
|
|
1204
|
+
return true
|
|
1205
|
+
case "/deactivate":
|
|
1206
|
+
if (id && this.registry.has(id)) {
|
|
1207
|
+
this.registry.setActive(id, false)
|
|
1208
|
+
this.notice(`Deactivated @${id}.`)
|
|
1209
|
+
} else this.notice(`/deactivate: unknown participant "${rawTarget ?? ""}".`, "error")
|
|
1210
|
+
return true
|
|
1211
|
+
case "/activate":
|
|
1212
|
+
if (id && this.registry.has(id)) {
|
|
1213
|
+
this.registry.setActive(id, true)
|
|
1214
|
+
this.notice(`Activated @${id}.`)
|
|
1215
|
+
} else this.notice(`/activate: unknown participant "${rawTarget ?? ""}".`, "error")
|
|
1216
|
+
return true
|
|
1217
|
+
case "/compact": {
|
|
1218
|
+
if (!id) {
|
|
1219
|
+
this.notice(`/compact: usage — /compact @agent`, "error")
|
|
1220
|
+
return true
|
|
1221
|
+
}
|
|
1222
|
+
const target = this.registry.get(id)
|
|
1223
|
+
if (!target) {
|
|
1224
|
+
this.notice(`/compact: unknown participant "${rawTarget ?? ""}".`, "error")
|
|
1225
|
+
return true
|
|
1226
|
+
}
|
|
1227
|
+
if (this.isBusy()) {
|
|
1228
|
+
this.notice(`/compact: wait until the current turn finishes.`, "error")
|
|
1229
|
+
return true
|
|
1230
|
+
}
|
|
1231
|
+
this.notice(`Compacting @${id}'s context…`)
|
|
1232
|
+
try {
|
|
1233
|
+
const result = await target.compact()
|
|
1234
|
+
this.notice(`@${id} compacted: ${result.tokensBefore} tokens before → summary generated.`)
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
this.notice(`/compact @${id} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1237
|
+
}
|
|
1238
|
+
return true
|
|
1239
|
+
}
|
|
1240
|
+
case "/help":
|
|
1241
|
+
this.notice(
|
|
1242
|
+
"Commands: /help, /kick @agent, /activate @agent, /deactivate @agent, " +
|
|
1243
|
+
"/compact @agent, /model @agent provider/id, /thinking [level|@agent level], " +
|
|
1244
|
+
"/stats [@agent], /chaining on|off, /default @agent|none, /fallback @agent|none, " +
|
|
1245
|
+
"/provider [list|add <name> <key>|remove <name>]"
|
|
1246
|
+
)
|
|
1247
|
+
return true
|
|
1248
|
+
case "/model": {
|
|
1249
|
+
const agentId = args[0]?.replace(/^@/, "").toLowerCase()
|
|
1250
|
+
const modelRef = args[1]
|
|
1251
|
+
if (!agentId || !modelRef) {
|
|
1252
|
+
this.notice("/model: usage — /model @agent provider/id", "error")
|
|
1253
|
+
return true
|
|
1254
|
+
}
|
|
1255
|
+
if (!this.registry.has(agentId)) {
|
|
1256
|
+
this.notice(`/model: unknown participant "@${agentId}".`, "error")
|
|
1257
|
+
return true
|
|
1258
|
+
}
|
|
1259
|
+
if (!this.registry.isAllowedModel(modelRef)) {
|
|
1260
|
+
this.notice(`/model: "${modelRef}" is not available. Use GET /api/models to list.`, "error")
|
|
1261
|
+
return true
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
await this.registry.update(agentId, { model: modelRef })
|
|
1265
|
+
this.notice(`@${agentId} model → ${modelRef}`)
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
this.notice(`/model @${agentId} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1268
|
+
}
|
|
1269
|
+
return true
|
|
1270
|
+
}
|
|
1271
|
+
case "/thinking": {
|
|
1272
|
+
const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const
|
|
1273
|
+
if (args[0]?.startsWith("@")) {
|
|
1274
|
+
// Per-agent: /thinking @agent level
|
|
1275
|
+
const agentId = args[0].replace(/^@/, "").toLowerCase()
|
|
1276
|
+
const level = args[1]
|
|
1277
|
+
if (!level || !(LEVELS as readonly string[]).includes(level)) {
|
|
1278
|
+
this.notice(`/thinking: usage — /thinking @agent ${LEVELS.join("|")}`, "error")
|
|
1279
|
+
return true
|
|
1280
|
+
}
|
|
1281
|
+
const p = this.registry.get(agentId)
|
|
1282
|
+
if (!p) {
|
|
1283
|
+
this.notice(`/thinking: unknown participant "@${agentId}".`, "error")
|
|
1284
|
+
return true
|
|
1285
|
+
}
|
|
1286
|
+
const available = p.getAvailableThinkingLevels?.()
|
|
1287
|
+
if (available && available.length > 0 && !available.includes(level)) {
|
|
1288
|
+
this.notice(`/thinking: "${level}" not available for @${agentId}. Available: ${available.join(", ")}`, "error")
|
|
1289
|
+
return true
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
await this.registry.setThinkingLevel(agentId, level as typeof LEVELS[number])
|
|
1293
|
+
this.notice(`@${agentId} thinking → ${level}`)
|
|
1294
|
+
} catch (err) {
|
|
1295
|
+
this.notice(`/thinking @${agentId} failed: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1296
|
+
}
|
|
1297
|
+
} else {
|
|
1298
|
+
// Global: /thinking level
|
|
1299
|
+
const level = args[0]
|
|
1300
|
+
if (!level || !(LEVELS as readonly string[]).includes(level)) {
|
|
1301
|
+
this.notice(`/thinking: usage — /thinking ${LEVELS.join("|")}`, "error")
|
|
1302
|
+
return true
|
|
1303
|
+
}
|
|
1304
|
+
config.thinkingLevel = level as typeof LEVELS[number]
|
|
1305
|
+
this.notice(`Global thinking → ${level}`)
|
|
1306
|
+
}
|
|
1307
|
+
return true
|
|
1308
|
+
}
|
|
1309
|
+
case "/stats": {
|
|
1310
|
+
if (id) {
|
|
1311
|
+
// Per-agent stats
|
|
1312
|
+
const p = this.registry.get(id)
|
|
1313
|
+
if (!p) {
|
|
1314
|
+
this.notice(`/stats: unknown participant "@${id}".`, "error")
|
|
1315
|
+
return true
|
|
1316
|
+
}
|
|
1317
|
+
const stats = p.getSessionStats?.()
|
|
1318
|
+
const ctx = p.getContextUsage?.()
|
|
1319
|
+
const parts: string[] = [`@${id}:`]
|
|
1320
|
+
if (stats) {
|
|
1321
|
+
const { input, output, cacheRead, total } = stats.tokens
|
|
1322
|
+
const cachePct = total > 0 ? Math.round((cacheRead / total) * 100) : 0
|
|
1323
|
+
parts.push(`${input}i / ${output}o · cache ${cachePct}% · ${stats.toolCalls} tools · ${stats.userMessages + stats.assistantMessages} msgs`)
|
|
1324
|
+
}
|
|
1325
|
+
if (ctx) {
|
|
1326
|
+
parts.push(`context: ${ctx.tokens ?? "?"}/${ctx.contextWindow} (${ctx.percent ?? "?"}%)`)
|
|
1327
|
+
}
|
|
1328
|
+
if (!stats && !ctx) parts.push("no stats yet")
|
|
1329
|
+
this.notice(parts.join(" · "))
|
|
1330
|
+
} else {
|
|
1331
|
+
// All agents summary
|
|
1332
|
+
for (const item of this.registry.roster()) {
|
|
1333
|
+
const p = this.registry.get(item.id)
|
|
1334
|
+
if (!p) continue
|
|
1335
|
+
const stats = p.getSessionStats?.()
|
|
1336
|
+
const ctx = p.getContextUsage?.()
|
|
1337
|
+
const parts: string[] = [`@${item.id}`]
|
|
1338
|
+
if (stats) {
|
|
1339
|
+
const { input, output, cacheRead, total } = stats.tokens
|
|
1340
|
+
const cachePct = total > 0 ? Math.round((cacheRead / total) * 100) : 0
|
|
1341
|
+
parts.push(`${input}i / ${output}o · cache ${cachePct}% · ${stats.toolCalls} tools`)
|
|
1342
|
+
}
|
|
1343
|
+
if (ctx) {
|
|
1344
|
+
parts.push(`ctx ${ctx.percent ?? "?"}%`)
|
|
1345
|
+
}
|
|
1346
|
+
if (!stats && !ctx) parts.push("no stats yet")
|
|
1347
|
+
this.notice(parts.join(" · "))
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return true
|
|
1351
|
+
}
|
|
1352
|
+
case "/chaining": {
|
|
1353
|
+
const val = args[0]?.toLowerCase()
|
|
1354
|
+
if (val === "on") {
|
|
1355
|
+
this.setChaining(true)
|
|
1356
|
+
this.notice("Chaining → on")
|
|
1357
|
+
} else if (val === "off") {
|
|
1358
|
+
this.setChaining(false)
|
|
1359
|
+
this.notice("Chaining → off")
|
|
1360
|
+
} else {
|
|
1361
|
+
this.notice("/chaining: usage — /chaining on|off", "error")
|
|
1362
|
+
}
|
|
1363
|
+
return true
|
|
1364
|
+
}
|
|
1365
|
+
case "/default": {
|
|
1366
|
+
if (!rawTarget || rawTarget.toLowerCase() === "none") {
|
|
1367
|
+
this.setDefaultAgent(null)
|
|
1368
|
+
this.notice("Default agent → none (first active)")
|
|
1369
|
+
} else {
|
|
1370
|
+
const agentId = rawTarget.replace(/^@/, "").toLowerCase()
|
|
1371
|
+
try {
|
|
1372
|
+
this.setDefaultAgent(agentId)
|
|
1373
|
+
this.notice(`Default agent → @${agentId}`)
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
this.notice(`/default: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return true
|
|
1379
|
+
}
|
|
1380
|
+
case "/fallback": {
|
|
1381
|
+
if (!rawTarget || rawTarget.toLowerCase() === "none") {
|
|
1382
|
+
this.setFallbackAgent(null)
|
|
1383
|
+
this.notice("Fallback routing → disabled")
|
|
1384
|
+
} else {
|
|
1385
|
+
const agentId = rawTarget.replace(/^@/, "").toLowerCase()
|
|
1386
|
+
try {
|
|
1387
|
+
this.setFallbackAgent(agentId)
|
|
1388
|
+
this.notice(`Fallback routing → @${agentId}`)
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
this.notice(`/fallback: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return true
|
|
1394
|
+
}
|
|
1395
|
+
case "/provider": {
|
|
1396
|
+
const subCmd = args[0]?.toLowerCase()
|
|
1397
|
+
if (subCmd === "list" || !subCmd) {
|
|
1398
|
+
// List providers
|
|
1399
|
+
const providers = this.registry.getProviderList()
|
|
1400
|
+
const lines = providers.map((p) =>
|
|
1401
|
+
`${p.configured ? "✓" : "○"} ${p.displayName} (${p.models} models)`,
|
|
1402
|
+
)
|
|
1403
|
+
this.notice(`Providers:\n${lines.join("\n")}`)
|
|
1404
|
+
} else if (subCmd === "add") {
|
|
1405
|
+
const providerName = args[1]
|
|
1406
|
+
const apiKey = args[2]
|
|
1407
|
+
if (!providerName || !apiKey) {
|
|
1408
|
+
this.notice("/provider add: usage — /provider add <name> <api_key>", "error")
|
|
1409
|
+
} else {
|
|
1410
|
+
try {
|
|
1411
|
+
this.registry.setProviderKey(providerName, apiKey)
|
|
1412
|
+
this.notice(`Provider "${providerName}" configured. Models should now be available.`)
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
this.notice(`/provider add failed: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
} else if (subCmd === "remove") {
|
|
1418
|
+
const providerName = args[1]
|
|
1419
|
+
if (!providerName) {
|
|
1420
|
+
this.notice("/provider remove: usage — /provider remove <name>", "error")
|
|
1421
|
+
} else {
|
|
1422
|
+
try {
|
|
1423
|
+
this.registry.removeProviderKey(providerName)
|
|
1424
|
+
this.notice(`Provider "${providerName}" removed.`)
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
this.notice(`/provider remove failed: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
} else {
|
|
1430
|
+
this.notice(`/provider: unknown sub-command "${subCmd}". Use: list, add <name> <key>, remove <name>`, "error")
|
|
1431
|
+
}
|
|
1432
|
+
return true
|
|
1433
|
+
}
|
|
1434
|
+
default:
|
|
1435
|
+
this.notice(`Unknown command "${cmd}".`, "error")
|
|
1436
|
+
return true
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Shared drain loop — replaces the 4 duplicated inline loops.
|
|
1441
|
+
* Returns true if the pipeline was paused by an ask_user (caller should return).
|
|
1442
|
+
* Handles parallel-wave questions correctly: posts ALL results from the wave
|
|
1443
|
+
* before pausing on the first question, so no agent output is silently dropped. */
|
|
1444
|
+
/** Decide who runs next from `fromId`'s reply and append them onto `target`.
|
|
1445
|
+
* Honors the chain-hop budget; when no @mention is found and a fallback agent
|
|
1446
|
+
* is configured, routes there with a nudge to pick the next agent. Shared by
|
|
1447
|
+
* the main drain loop and the ask-user resume path so routing is identical in
|
|
1448
|
+
* both — the resume path previously pushed @mentions onto a queue it then
|
|
1449
|
+
* discarded, silently dropping a handoff made right after answering a question. */
|
|
1450
|
+
private async proposeChain(fromId: string, reply: string, target: Participant[]): Promise<Participant[]> {
|
|
1451
|
+
const mentioned = this.resolveAgentMentions(reply, fromId)
|
|
1452
|
+
if (mentioned.length > 0) {
|
|
1453
|
+
// De-dupe against the pending queue: if e.g. scout AND builder both hand
|
|
1454
|
+
// off to @planner in the same pass, enqueue the planner once instead of
|
|
1455
|
+
// running it back-to-back (the loop kept returning to it 2-3× in a row).
|
|
1456
|
+
const next = mentioned.filter((p) => !target.includes(p))
|
|
1457
|
+
if (next.length === 0) return []
|
|
1458
|
+
if (this.routingMode !== "auto") {
|
|
1459
|
+
// semi/manual: hand these back for human approval. Don't enqueue or spend
|
|
1460
|
+
// hop budget yet — that happens when the human approves.
|
|
1461
|
+
return next
|
|
1462
|
+
}
|
|
1463
|
+
if (this.chainBudget < this.maxChainHops) {
|
|
1464
|
+
this.chainBudget += next.length
|
|
1465
|
+
target.push(...next)
|
|
1466
|
+
this.emit("turn", { phase: "chain", from: fromId, targets: next.map((t) => t.persona.id) })
|
|
1467
|
+
} else {
|
|
1468
|
+
this.notice(`Chain budget exhausted (${this.maxChainHops} hops) — stopping.`, "info")
|
|
1469
|
+
}
|
|
1470
|
+
return []
|
|
1471
|
+
}
|
|
1472
|
+
if (
|
|
1473
|
+
this.fallbackAgentId &&
|
|
1474
|
+
fromId !== this.fallbackAgentId &&
|
|
1475
|
+
this.chainBudget < this.maxChainHops
|
|
1476
|
+
) {
|
|
1477
|
+
const fb = this.registry.get(this.fallbackAgentId)
|
|
1478
|
+
if (fb && fb.active && !target.includes(fb)) {
|
|
1479
|
+
this.chainBudget += 1
|
|
1480
|
+
target.push(fb)
|
|
1481
|
+
this.notice(`No handoff detected — routing to @${this.fallbackAgentId}`, "info")
|
|
1482
|
+
this.emit("turn", { phase: "chain", from: fromId, targets: [this.fallbackAgentId] })
|
|
1483
|
+
// Inject routing context so the fallback agent knows why it's being called.
|
|
1484
|
+
await fb.sendCustomMessage(
|
|
1485
|
+
{
|
|
1486
|
+
customType: "routing_fallback",
|
|
1487
|
+
content: `(Routing fallback: @${fromId} finished without handing off. Based on the conversation state, decide who should go next — @-mention them in your reply. If the work is complete, say so without mentioning anyone.)`,
|
|
1488
|
+
display: false,
|
|
1489
|
+
},
|
|
1490
|
+
{ deliverAs: "nextTurn" },
|
|
1491
|
+
)
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return []
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** Broadcast the current routing proposal so the UI can render the approval card. */
|
|
1498
|
+
private emitRoutingProposed(): void {
|
|
1499
|
+
if (!this.pendingRoute) return
|
|
1500
|
+
this.emit("routing", {
|
|
1501
|
+
type: "proposed",
|
|
1502
|
+
proposals: this.pendingRoute.proposals.map((p) => ({
|
|
1503
|
+
from: p.fromId,
|
|
1504
|
+
target: p.target.persona.id,
|
|
1505
|
+
targetName: p.target.persona.name,
|
|
1506
|
+
})),
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Serializable snapshot of a pending routing proposal (for state bootstrap),
|
|
1511
|
+
* or null when nothing is awaiting approval. */
|
|
1512
|
+
getPendingRoute(): { proposals: Array<{ from: string; target: string; targetName: string }> } | null {
|
|
1513
|
+
if (!this.pendingRoute) return null
|
|
1514
|
+
return {
|
|
1515
|
+
proposals: this.pendingRoute.proposals.map((p) => ({
|
|
1516
|
+
from: p.fromId,
|
|
1517
|
+
target: p.target.persona.id,
|
|
1518
|
+
targetName: p.target.persona.name,
|
|
1519
|
+
})),
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/** Apply the human's decision on a pending routing proposal (semi/manual).
|
|
1524
|
+
* Serialized onto the room's turn chain so it can't race a running turn. */
|
|
1525
|
+
resolveRoute(decision: RouteDecision): void {
|
|
1526
|
+
this.chain = this.chain.then(() => this.processRouteDecision(decision)).catch((err) => {
|
|
1527
|
+
this.notice(`Room error: ${err instanceof Error ? err.message : String(err)}`, "error")
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
private async processRouteDecision(decision: RouteDecision): Promise<void> {
|
|
1532
|
+
const pr = this.pendingRoute
|
|
1533
|
+
if (!pr) return // nothing pending (already resolved, or raced an abort)
|
|
1534
|
+
this.pendingRoute = null
|
|
1535
|
+
this.aborted = false
|
|
1536
|
+
|
|
1537
|
+
let toRun: Participant[] = []
|
|
1538
|
+
if (decision.action === "approve") {
|
|
1539
|
+
toRun = pr.proposals.map((p) => p.target)
|
|
1540
|
+
} else if (decision.action === "redirect") {
|
|
1541
|
+
toRun = (decision.targetIds ?? [])
|
|
1542
|
+
.map((id) => this.registry.get(id))
|
|
1543
|
+
.filter((p): p is Participant => !!p && p.active)
|
|
1544
|
+
}
|
|
1545
|
+
// "drop" → toRun stays empty: continue with whatever work was already held.
|
|
1546
|
+
|
|
1547
|
+
const fresh = toRun.filter((p) => !pr.heldQueue.includes(p))
|
|
1548
|
+
if (fresh.length > 0) {
|
|
1549
|
+
this.chainBudget += fresh.length
|
|
1550
|
+
pr.heldQueue.push(...fresh)
|
|
1551
|
+
this.emit("turn", { phase: "chain", from: null, targets: fresh.map((t) => t.persona.id) })
|
|
1552
|
+
}
|
|
1553
|
+
this.emit("routing", { type: "resolved", action: decision.action, targets: fresh.map((t) => t.persona.id) })
|
|
1554
|
+
|
|
1555
|
+
this.queue = pr.heldQueue
|
|
1556
|
+
const paused = await this.drainQueue()
|
|
1557
|
+
if (paused) {
|
|
1558
|
+
await this.emitWorkspace()
|
|
1559
|
+
await this.saveCurrent()
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
this.queue = []
|
|
1563
|
+
await this.endTurn()
|
|
1564
|
+
await this.saveCurrent()
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
private async drainQueue(): Promise<boolean> {
|
|
1568
|
+
while (this.queue.length > 0 && !this.aborted) {
|
|
1569
|
+
const group = this.nextGroup()
|
|
1570
|
+
if (group.length > 1) {
|
|
1571
|
+
this.notice(`running ${group.length} in parallel: ${group.map((g) => `@${g.persona.id}`).join(" ")}`)
|
|
1572
|
+
this.emit("turn", { phase: "parallel", targets: group.map((g) => g.persona.id) })
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const results = await this.runWave(group)
|
|
1576
|
+
|
|
1577
|
+
// Collect which results have questions (we pause on the first one,
|
|
1578
|
+
// but still post ALL results from this wave to avoid data loss).
|
|
1579
|
+
let paused = false
|
|
1580
|
+
let pauseAskerId: string | null = null
|
|
1581
|
+
let pauseQuestion: string | null = null
|
|
1582
|
+
const waveProposals: RouteProposal[] = []
|
|
1583
|
+
|
|
1584
|
+
for (const out of results) {
|
|
1585
|
+
if (!out || this.aborted) continue
|
|
1586
|
+
|
|
1587
|
+
if (out.question && !paused) {
|
|
1588
|
+
// First question in this wave — remember it, but don't return yet.
|
|
1589
|
+
paused = true
|
|
1590
|
+
pauseAskerId = out.target.persona.id
|
|
1591
|
+
pauseQuestion = out.question
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Post the result (with question field if applicable).
|
|
1595
|
+
this.post(out.target.persona.id, out.target.persona.name, out.reply || "(no response)", out.activity, out.reasoning, undefined, out.question)
|
|
1596
|
+
if (receiptHasChanges(out.receipt)) this.emit("receipt", out.receipt)
|
|
1597
|
+
out.target.cursor = this.transcript.length
|
|
1598
|
+
|
|
1599
|
+
// Chain from this reply (even if it had a question — the question is
|
|
1600
|
+
// posted as part of the message, so @mentions in the text still chain).
|
|
1601
|
+
if (this.chaining) {
|
|
1602
|
+
const proposed = await this.proposeChain(out.target.persona.id, out.reply, this.queue)
|
|
1603
|
+
for (const t of proposed) {
|
|
1604
|
+
if (!waveProposals.some((wp) => wp.target === t)) {
|
|
1605
|
+
waveProposals.push({ fromId: out.target.persona.id, target: t })
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Inject work receipt into the next agent in queue (if there is one and there are changes).
|
|
1611
|
+
if (receiptHasChanges(out.receipt) && this.queue.length > 0) {
|
|
1612
|
+
const nextTarget = this.queue[0]
|
|
1613
|
+
await nextTarget.sendCustomMessage(
|
|
1614
|
+
{ customType: "work_receipt", content: formatReceipt(out.receipt), display: false },
|
|
1615
|
+
{ deliverAs: "nextTurn" },
|
|
1616
|
+
)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// If we encountered a question in this wave, pause now (after all wave results are posted).
|
|
1621
|
+
if (paused) {
|
|
1622
|
+
this.pendingQuestion = { askerId: pauseAskerId!, heldQueue: [...this.queue] }
|
|
1623
|
+
this.queue = []
|
|
1624
|
+
this.emit("turn", { phase: "pause", askerId: pauseAskerId!, question: pauseQuestion! })
|
|
1625
|
+
return true
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// semi/manual: if any handoffs were proposed this wave, pause for approval
|
|
1629
|
+
// before running them. The held queue resumes after the human decides.
|
|
1630
|
+
if (waveProposals.length > 0) {
|
|
1631
|
+
this.pendingRoute = { proposals: waveProposals, heldQueue: [...this.queue] }
|
|
1632
|
+
this.queue = []
|
|
1633
|
+
this.emitRoutingProposed()
|
|
1634
|
+
return true
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return false
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** Stop everything: clear the pending queue and abort every running agent
|
|
1641
|
+
* (a parallel wave can have several in flight at once). */
|
|
1642
|
+
async abortCurrent(): Promise<boolean> {
|
|
1643
|
+
this.aborted = true
|
|
1644
|
+
// Mark an in-flight goal as cancelled. The goal-eval loop resets `aborted`
|
|
1645
|
+
// each iteration, so a separate sticky flag is what actually makes it stop
|
|
1646
|
+
// (see runGoalEval). submitGoal() clears it for the next goal run.
|
|
1647
|
+
if (this.goalText !== null && this.goalStatus === "running") {
|
|
1648
|
+
this.goalCancelled = true
|
|
1649
|
+
}
|
|
1650
|
+
const had = this.queue.length > 0 || this.running.size > 0 || this.pendingQuestion !== null || this.pendingRoute !== null
|
|
1651
|
+
this.queue = []
|
|
1652
|
+
this.pendingQuestion = null
|
|
1653
|
+
this.pendingRoute = null
|
|
1654
|
+
await Promise.all([...this.running].map((p) => p.abort()))
|
|
1655
|
+
return had
|
|
1656
|
+
}
|
|
1657
|
+
}
|