@rien7/agent-center-host 0.0.0-stage → 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.
@@ -0,0 +1,1245 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * OarDriver: answers turns with a real agent run by an oar runtime (Claude Code by
11
+ * default, pi, …). One oar session per agent, opened on demand (resumed from the agent's
12
+ * `resumeSessionId`), closed after `idleMinutes` without turns.
13
+ *
14
+ * - Tokens: each agent's tools use the agent's own Center token: host.json's
15
+ * `agents[].token` when given, else one the host gets from Center (agent.issue_token,
16
+ * kept in agent-tokens.json; agent-tokens.ts).
17
+ * - Tools: a host-served MCP server per agent (`serveMcpTools`, loopback HTTP + random
18
+ * bearer) with the Center tools (center-tools.ts), proxied to core with the agent's own
19
+ * token. The context's domains (the page or record the user is looking at) are added as
20
+ * direct tools with full schemas for the rest of the session when the runtime takes MCP
21
+ * tool-list changes (pi: full, claude: additions); otherwise they are inlined in the
22
+ * turn's prefix.
23
+ * - Memory: `<workdir>/agents/<id>/memory/` (memory.ts) is the session's cwd subtree; the
24
+ * runtime's own file tools edit it.
25
+ * - Records: every raw oar record is appended to
26
+ * `<workdir>/records/<agentId>/<sessionId>-<opened>.jsonl`.
27
+ * - Sub-agents (crew-tools.ts): a main agent's agent_spawn makes a core agent with
28
+ * parentAgentId (this host's, restricted token, own workdir and memory) whose session
29
+ * oar's crew (`@botiverse/oar/agents`) opens. Its turns are core turns like any other:
30
+ * the parent's task and follow-ups are turn.open turns in the child's conversation, the
31
+ * user can chat with it directly. Each report of a turn the parent started goes back
32
+ * to the parent: into a pending agent_wait, else as a turn.open turn in the parent's
33
+ * conversation, with a card that opens the child's conversation. Children don't spawn.
34
+ * - A turn a host restart interrupted is run again once, with 「上一轮被打断,请继续」.
35
+ *
36
+ * Turn ↔ oar turn: a delivery that lands `prompted` owns the next oar turn; `steered`
37
+ * takes over the running one (the earlier core turn finishes as it stands); `queued`
38
+ * waits for the next one. Events of the root agent go to the current owner.
39
+ */
40
+ import { randomUUID } from "node:crypto";
41
+ import { createWriteStream } from "node:fs";
42
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
43
+ import { join } from "node:path";
44
+ import { AgentTokens, defaultAgentScopes } from "./agent-tokens.js";
45
+ import { CenterApi, CenterTools, centerInstructions, centerServedTools, compactSchema, contextDomains, recordCards, } from "./center-tools.js";
46
+ import { CoreError } from "./core-client.js";
47
+ import { crewServedTools } from "./crew-tools.js";
48
+ import { MemorySync } from "./memory.js";
49
+ import { interruptedText, systemPrompt, turnText } from "./prompt.js";
50
+ import { bareToolName, coreToolTitle, inputDetail, toolTitle, } from "./tool-titles.js";
51
+ /** Runtime tools an agent never gets (no shell; Claude Code's web and sub-agent tools). */
52
+ const defaultDisallowed = {
53
+ pi: ["bash"],
54
+ claude: [
55
+ "Bash",
56
+ "BashOutput",
57
+ "KillShell",
58
+ "WebFetch",
59
+ "WebSearch",
60
+ "Task",
61
+ "NotebookEdit",
62
+ ],
63
+ };
64
+ /** Sub-agents running at once (all parents together). */
65
+ const maxRunningChildren = 4;
66
+ /** Sub-agents one parent may have open. */
67
+ const maxChildren = 8;
68
+ /**
69
+ * Merges consecutive text deltas for up to `holdMs` (oar's `coalesceText` held pi's text
70
+ * until the message ended, so streaming is merged here instead).
71
+ */
72
+ function textSink(sink, holdMs = 100) {
73
+ let text = "";
74
+ let timer = null;
75
+ const flush = () => {
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ timer = null;
79
+ if (text)
80
+ sink.emit({ type: "textDelta", text });
81
+ text = "";
82
+ };
83
+ return {
84
+ emit(event) {
85
+ if (event.type === "textDelta") {
86
+ text += event.text;
87
+ timer ??= setTimeout(flush, holdMs);
88
+ return;
89
+ }
90
+ flush();
91
+ sink.emit(event);
92
+ },
93
+ };
94
+ }
95
+ const clip = (text, max) => {
96
+ const chars = Array.from(text);
97
+ return chars.length > max ? `${chars.slice(0, max).join("")}…` : text;
98
+ };
99
+ const parse = (input) => {
100
+ if (!input)
101
+ return null;
102
+ try {
103
+ const value = JSON.parse(input);
104
+ return value && typeof value === "object" && !Array.isArray(value)
105
+ ? value
106
+ : null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ };
112
+ const conversationUri = (id) => `agent:conversation/${id}`;
113
+ /** The conversation a host-opened turn came from (its context), or null for the user's. */
114
+ const fromConversation = (context) => {
115
+ const uri = context
116
+ ?.kind?.uri;
117
+ return typeof uri === "string" && uri.startsWith("agent:conversation/")
118
+ ? uri.slice("agent:conversation/".length)
119
+ : null;
120
+ };
121
+ class AgentRun {
122
+ agent;
123
+ spec;
124
+ dir;
125
+ runtimeId;
126
+ model;
127
+ tools;
128
+ memory;
129
+ /** Write scopes of its token (what a child may get at most). */
130
+ scopes;
131
+ parent;
132
+ live = null;
133
+ opening = null;
134
+ idle = null;
135
+ /** Prompt state of the current (or next) session. */
136
+ direct = new Set();
137
+ inlined = new Set();
138
+ /** MEMORY.md as the session last saw it. */
139
+ memorySeen = null;
140
+ /** driver.run calls in progress. */
141
+ busy = 0;
142
+ // A child:
143
+ /** Its crew id while its session is open. */
144
+ crewId = null;
145
+ /** Owners of the turn its session is being opened for. */
146
+ pending = [];
147
+ /** The owner of the oar turn that ended last (its report follows). */
148
+ lastEnded = null;
149
+ /** Its conversation with its parent. */
150
+ conversationId = null;
151
+ // A parent:
152
+ children = new Map();
153
+ /** Reports not delivered yet. */
154
+ inbox = [];
155
+ waiters = new Set();
156
+ constructor(agent, spec, driver, client, token, parent = null) {
157
+ this.agent = agent;
158
+ this.spec = spec;
159
+ this.parent = parent;
160
+ this.scopes = token.scopes;
161
+ this.dir = join(driver.config.workdir, "agents", agent.id);
162
+ this.runtimeId = spec.runtime ?? driver.config.runtime;
163
+ this.model = spec.model ?? driver.config.model;
164
+ this.tools = new CenterTools({
165
+ api: new CenterApi(driver.server, token.token, { fetch: driver.fetcher }),
166
+ timeZone: driver.config.timeZone,
167
+ now: driver.now,
168
+ });
169
+ this.memory = new MemorySync({
170
+ client,
171
+ agentId: agent.id,
172
+ agentName: agent.name,
173
+ agentDir: this.dir,
174
+ log: (m) => driver.log(`${agent.name}: ${m}`),
175
+ });
176
+ }
177
+ /** A child by name or id. */
178
+ child(ref) {
179
+ const all = [...this.children.values()];
180
+ return (this.children.get(ref) ??
181
+ all.findLast((c) => c.agent.name === ref) ??
182
+ all.findLast((c) => c.agent.name.includes(ref)) ??
183
+ null);
184
+ }
185
+ }
186
+ /** Scopes a token must never carry (Center refuses them for agent tokens too). */
187
+ const unsafeScopes = ["*", "agent:host", "core:admin", "core:*", "agent:*"];
188
+ export class OarDriver {
189
+ name;
190
+ config;
191
+ server;
192
+ fetcher;
193
+ now;
194
+ log = () => { };
195
+ client = null;
196
+ context = null;
197
+ runs = new Map();
198
+ loadOar;
199
+ loadAgents;
200
+ loadRuntime;
201
+ oarModule = null;
202
+ runtimes = new Map();
203
+ tokenOption;
204
+ tokens = null;
205
+ crew = null;
206
+ /** Runtimes as the crew sees them: sessions get the child's prompt, tools and limits. */
207
+ crewRuntimes = new Map();
208
+ /** Children whose session is being opened, by cwd. */
209
+ spawning = new Map();
210
+ opened = new Map();
211
+ resumed = null;
212
+ constructor(options) {
213
+ this.config = options.config;
214
+ this.name = options.config.runtime;
215
+ this.server = options.server;
216
+ this.fetcher = options.fetch;
217
+ this.now = options.now ?? Date.now;
218
+ this.tokenOption = options.tokens;
219
+ this.loadOar = options.oar ?? (() => import("@botiverse/oar"));
220
+ this.loadAgents = options.agents ?? (() => import("@botiverse/oar/agents"));
221
+ this.loadRuntime =
222
+ options.runtime ??
223
+ (async (id) => {
224
+ const module = (await import(__rewriteRelativeImportExtension(`@botiverse/oar/runtimes/${id}`)));
225
+ const runtime = module[`${id}Runtime`];
226
+ if (!runtime)
227
+ throw new Error(`oar has no runtime ${id}`);
228
+ return runtime;
229
+ });
230
+ }
231
+ oar() {
232
+ this.oarModule ??= this.loadOar();
233
+ return this.oarModule;
234
+ }
235
+ runtime(id) {
236
+ let runtime = this.runtimes.get(id);
237
+ if (!runtime) {
238
+ runtime = this.loadRuntime(id);
239
+ this.runtimes.set(id, runtime);
240
+ }
241
+ return runtime;
242
+ }
243
+ /** The agent's token: host.json's, else the saved one Center still takes, else a new one. */
244
+ async token(agent, spec, scopes) {
245
+ const own = spec.token;
246
+ if (own)
247
+ return { token: own, scopes: spec.scopes ?? [] };
248
+ if (!this.tokens)
249
+ throw new Error("no token store");
250
+ const { token, issued } = await this.tokens.get(agent.id, scopes, (t) => this.accepted(t));
251
+ if (issued)
252
+ this.log(`${agent.name}: got its own Center token`);
253
+ return { token, scopes };
254
+ }
255
+ /** Does Center still take this token (false only on 401)? */
256
+ async accepted(token) {
257
+ const response = await (this.fetcher ?? fetch)(`${this.server.replace(/\/+$/, "")}/v1/ops/core.principal.me`, {
258
+ method: "POST",
259
+ headers: {
260
+ authorization: `Bearer ${token}`,
261
+ "content-type": "application/json",
262
+ },
263
+ body: "{}",
264
+ });
265
+ await response.body?.cancel();
266
+ if (response.status === 401)
267
+ return false;
268
+ if (!response.ok)
269
+ throw new Error(`core.principal.me failed with ${response.status}`);
270
+ return true;
271
+ }
272
+ /** Make the agent's run: token, safety check, workdir, memory. */
273
+ async prepare(agent, spec, scopes, parent) {
274
+ const client = this.client;
275
+ const token = await this.token(agent, spec, scopes).catch((error) => {
276
+ throw new Error(`agent ${agent.name}: no Center token (${error.message}); put one in host.json (agents[].token) or update Center`);
277
+ });
278
+ const run = new AgentRun(agent, spec, this, client, token, parent);
279
+ // The agent's token must not be able to act as a host or an admin.
280
+ const me = await run.tools.api
281
+ .op("core.principal.me", {})
282
+ .catch((error) => {
283
+ throw new Error(`agent ${agent.name}: its token was rejected (${error.message})`);
284
+ });
285
+ const granted = me.principal?.scopes ?? me.scopes ?? [];
286
+ const unsafe = granted.filter((s) => unsafeScopes.includes(s));
287
+ if (unsafe.length)
288
+ throw new Error(`agent ${agent.name}: its token has ${unsafe.join(", ")}; give the agent a narrower token (e.g. *:read,agent:read,memo:write,reminder:write,calendar:write)`);
289
+ this.runs.set(agent.id, run);
290
+ if (parent)
291
+ parent.children.set(agent.id, run);
292
+ await mkdir(run.dir, { recursive: true });
293
+ await run.memory.start().catch((error) => {
294
+ this.log(`${agent.name}: memory sync failed (${error.message})`);
295
+ });
296
+ return run;
297
+ }
298
+ async start(context) {
299
+ this.client = context.client;
300
+ this.context = context;
301
+ this.log = context.log;
302
+ this.tokens =
303
+ this.tokenOption === undefined
304
+ ? new AgentTokens({ client: context.client, server: this.server })
305
+ : (this.tokenOption ??
306
+ new AgentTokens({
307
+ client: context.client,
308
+ server: this.server,
309
+ path: null,
310
+ }));
311
+ if (this.config.piAgentDir)
312
+ process.env.OAR_PI_AGENT_DIR = this.config.piAgentDir;
313
+ process.env.PI_TELEMETRY ??= "0";
314
+ const children = [];
315
+ for (const agent of context.agents) {
316
+ const spec = this.config.agents.find((a) => a.id === agent.id) ??
317
+ this.config.agents.find((a) => !a.id && a.name === agent.name);
318
+ if (!spec) {
319
+ if (agent.parentAgentId)
320
+ children.push(agent);
321
+ else
322
+ this.log(`agent ${agent.name} is not in host.json; its turns will fail`);
323
+ continue;
324
+ }
325
+ await this.prepare(agent, spec, spec.scopes ?? defaultAgentScopes, null);
326
+ }
327
+ // Sub-agents from before a restart: their parent's settings, their saved token.
328
+ for (const agent of children) {
329
+ const parent = this.runs.get(agent.parentAgentId);
330
+ if (!parent || parent.parent)
331
+ continue;
332
+ const saved = await this.tokens.entry(agent.id);
333
+ await this.prepare(agent, this.childSpec(parent, agent.name), saved?.scopes ?? [], parent).catch((error) => this.log(`${agent.name}: ${error.message}`));
334
+ }
335
+ // Warm oar up so the first turn doesn't pay for the import.
336
+ this.oar().catch(() => { });
337
+ }
338
+ childSpec(parent, name) {
339
+ return {
340
+ name,
341
+ runtime: parent.runtimeId,
342
+ ...(parent.model ? { model: parent.model } : {}),
343
+ ...(parent.spec.disallowedTools
344
+ ? { disallowedTools: parent.spec.disallowedTools }
345
+ : {}),
346
+ };
347
+ }
348
+ async stop() {
349
+ await Promise.allSettled([...this.runs.values()].map(async (run) => {
350
+ await this.close(run);
351
+ await run.memory.stop();
352
+ }));
353
+ const crew = this.crew;
354
+ this.crew = null;
355
+ if (crew)
356
+ await (await crew.catch(() => null))?.close();
357
+ }
358
+ /** Turns already run again after a restart (`<workdir>/resumed-turns.json`). */
359
+ async resumedTurns() {
360
+ if (this.resumed)
361
+ return this.resumed;
362
+ let ids = [];
363
+ try {
364
+ ids = JSON.parse(await readFile(join(this.config.workdir, "resumed-turns.json"), "utf8"));
365
+ }
366
+ catch { }
367
+ this.resumed = new Set(Array.isArray(ids) ? ids.map(String) : []);
368
+ return this.resumed;
369
+ }
370
+ async markResumed(turnId) {
371
+ const ids = await this.resumedTurns();
372
+ ids.add(turnId);
373
+ const kept = [...ids].slice(-200);
374
+ await mkdir(this.config.workdir, { recursive: true });
375
+ await writeFile(join(this.config.workdir, "resumed-turns.json"), `${JSON.stringify(kept)}\n`);
376
+ }
377
+ async run(input, sink, signal) {
378
+ const run = this.runs.get(input.agent.id);
379
+ if (!run) {
380
+ sink.emit({
381
+ type: "failed",
382
+ reason: `这个 host 没有配置 agent「${input.agent.name}」(host.json agents)`,
383
+ });
384
+ return;
385
+ }
386
+ run.busy++;
387
+ try {
388
+ await this.answer(run, input, sink, signal);
389
+ }
390
+ finally {
391
+ run.busy--;
392
+ this.flushInbox(run);
393
+ }
394
+ }
395
+ async answer(run, input, sink, signal) {
396
+ let interrupted = false;
397
+ if (input.appended > 0) {
398
+ // oar can't replay half a reply: run the turn again once, saying so.
399
+ if ((await this.resumedTurns()).has(input.turn.id)) {
400
+ sink.emit({
401
+ type: "failed",
402
+ reason: "host 又重启了,这条回复中断了,请重新发送",
403
+ });
404
+ return;
405
+ }
406
+ await this.markResumed(input.turn.id).catch((error) => this.log(`resumed-turns.json: ${error.message}`));
407
+ interrupted = true;
408
+ this.log(`${run.agent.name}: turn ${input.turn.id} was interrupted; running it again`);
409
+ }
410
+ if (run.idle)
411
+ clearTimeout(run.idle);
412
+ run.idle = null;
413
+ if (run.parent)
414
+ run.conversationId ??= input.conversation.id;
415
+ const opened = this.opened.get(input.turn.id);
416
+ this.opened.delete(input.turn.id);
417
+ const owner = {
418
+ turnId: input.turn.id,
419
+ conversationId: input.conversation.id,
420
+ fromParent: run.parent ? fromConversation(input.message.context) : null,
421
+ sink: textSink(sink),
422
+ finished: false,
423
+ chain: Promise.resolve(),
424
+ resolve: () => { },
425
+ };
426
+ const ended = new Promise((resolve) => {
427
+ owner.resolve = resolve;
428
+ });
429
+ if (interrupted)
430
+ owner.sink.emit({
431
+ type: "textDelta",
432
+ text: "\n\n(host 重启了,接着说)\n\n",
433
+ });
434
+ for (const card of opened?.cards ?? [])
435
+ owner.sink.emit({ type: "card", card });
436
+ // A child without a session: the crew opens one with this turn as its task.
437
+ if (run.parent && !run.live) {
438
+ run.pending.push(owner);
439
+ const text = await this.prompt(run, null, input, opened, interrupted);
440
+ // Once the crew prompted the task, abort it like any oar turn.
441
+ const onAbort = () => run.live?.owners.includes(owner)
442
+ ? this.abortOwner(run, run.live, owner)
443
+ : this.finish(run.pending, owner, null);
444
+ signal.addEventListener("abort", onAbort, { once: true });
445
+ const refused = await this.spawnChild(run, text).catch((error) => error.message);
446
+ if (refused)
447
+ this.finish(run.pending, owner, {
448
+ type: "failed",
449
+ reason: `打开子 agent 失败:${refused}`,
450
+ });
451
+ await ended;
452
+ signal.removeEventListener("abort", onAbort);
453
+ run.memory.sync();
454
+ this.scheduleIdle(run);
455
+ return;
456
+ }
457
+ let live;
458
+ try {
459
+ live = await this.open(run);
460
+ }
461
+ catch (error) {
462
+ sink.emit({
463
+ type: "failed",
464
+ reason: `打开 ${run.runtimeId} 会话失败:${error.message}`,
465
+ });
466
+ return;
467
+ }
468
+ if (signal.aborted)
469
+ return;
470
+ const text = await this.prompt(run, live, input, opened, interrupted).catch((error) => {
471
+ this.log(`${run.agent.name}: prompt prefix failed (${error.message})`);
472
+ return input.message.text;
473
+ });
474
+ const onAbort = () => this.abortOwner(run, live, owner);
475
+ signal.addEventListener("abort", onAbort, { once: true });
476
+ live.owners.push(owner);
477
+ try {
478
+ const result = await live.session.deliver(text, {
479
+ when: "now",
480
+ inputId: input.turn.id,
481
+ origin: opened?.origin ?? { kind: "user" },
482
+ });
483
+ if (result.landed === "rejected") {
484
+ this.finish(live.owners, owner, {
485
+ type: "failed",
486
+ reason: `${run.runtimeId} 拒绝了这条消息:${result.reason}`,
487
+ });
488
+ }
489
+ else if (result.landed !== "queued") {
490
+ // prompted / steered: earlier owners hand the running oar turn over.
491
+ for (const earlier of live.owners.slice(0, live.owners.indexOf(owner)))
492
+ this.finish(live.owners, earlier, { type: "done" });
493
+ }
494
+ }
495
+ catch (error) {
496
+ this.finish(live.owners, owner, {
497
+ type: "failed",
498
+ reason: error.message,
499
+ });
500
+ }
501
+ await ended;
502
+ signal.removeEventListener("abort", onAbort);
503
+ run.memory.sync();
504
+ this.scheduleIdle(run);
505
+ }
506
+ /** The user's text with time, context, memory and the context's domain tools. */
507
+ async prompt(run, live, input, opened, interrupted) {
508
+ const memory = await run.memory.index();
509
+ const memoryChanged = memory !== run.memorySeen;
510
+ run.memorySeen = memory;
511
+ const domains = contextDomains(input.message.context);
512
+ const visible = new Set((await run.tools.tools()).map((t) => t.name.split("_")[0]));
513
+ const wanted = domains.filter((d) => visible.has(d));
514
+ let inline = null;
515
+ const runtime = await this.runtime(run.runtimeId);
516
+ const canChange = live !== null &&
517
+ this.config.contextTools === "direct" &&
518
+ (runtime.mcpToolsListChanged === "full" ||
519
+ runtime.mcpToolsListChanged === "additions");
520
+ const fresh = wanted.filter((d) => !run.direct.has(d) && !run.inlined.has(d));
521
+ if (fresh.length) {
522
+ if (canChange && live) {
523
+ for (const d of fresh)
524
+ run.direct.add(d);
525
+ live.served.setTools(await this.servedTools(run));
526
+ }
527
+ else {
528
+ for (const d of fresh)
529
+ run.inlined.add(d);
530
+ inline = await run.tools.expandDomains(fresh);
531
+ }
532
+ }
533
+ const text = opened?.text ?? input.message.text;
534
+ return turnText(interrupted ? interruptedText(text) : text, {
535
+ timeZone: this.config.timeZone,
536
+ now: this.now(),
537
+ context: input.message.context,
538
+ memory: memoryChanged ? memory : null,
539
+ tools: inline,
540
+ });
541
+ }
542
+ /** The meta tools, the sub-agent tools and every direct tool of the session's expanded domains. */
543
+ async servedTools(run) {
544
+ const base = await centerServedTools(run.tools);
545
+ if (this.config.subagents && !run.parent)
546
+ base.push(...crewServedTools({
547
+ spawn: (input) => this.spawnTool(run, input),
548
+ send: (input) => this.sendTool(run, input),
549
+ wait: (input, signal) => this.waitTool(run, input, signal),
550
+ close: (input) => this.closeTool(run, input),
551
+ }, { writeScopes: run.scopes }));
552
+ if (!run.direct.size)
553
+ return base;
554
+ const taken = new Set(base.map((t) => t.name));
555
+ const own = await Promise.all([...run.direct].map((d) => run.tools.contextTools(d)));
556
+ const direct = own
557
+ .flatMap(({ shown }) => shown)
558
+ .filter((t) => !taken.has(t.name))
559
+ .map((t) => ({
560
+ name: t.name,
561
+ description: `${coreToolTitle(t.name, t.readOnly)}。${t.description}`,
562
+ inputSchema: compactSchema(t.inputSchema),
563
+ handler: (input, { signal }) => run.tools.call(t.name, input, signal),
564
+ }));
565
+ return [...base, ...direct];
566
+ }
567
+ /** What every session of the agent is opened with (cwd, prompt, tools, limits). */
568
+ async sessionOptions(run, served) {
569
+ const options = {
570
+ cwd: run.dir,
571
+ name: run.agent.name,
572
+ ...(run.model ? { model: run.model } : {}),
573
+ systemPrompt: systemPrompt({
574
+ name: run.agent.name,
575
+ role: run.agent.role ?? run.spec.role,
576
+ timeZone: this.config.timeZone,
577
+ catalog: await run.tools.domainCatalog(),
578
+ subagents: this.config.subagents && !run.parent,
579
+ parent: run.parent?.agent.name ?? null,
580
+ }),
581
+ mcpServers: [served.server],
582
+ disallowedTools: run.spec.disallowedTools ?? defaultDisallowed[run.runtimeId] ?? [],
583
+ };
584
+ return options;
585
+ }
586
+ async serve(run) {
587
+ const oar = await this.oar();
588
+ return oar.serveMcpTools({
589
+ name: "center",
590
+ tools: await this.servedTools(run),
591
+ instructions: centerInstructions,
592
+ });
593
+ }
594
+ async open(run) {
595
+ if (run.live)
596
+ return run.live;
597
+ run.opening ??= this.doOpen(run).finally(() => {
598
+ run.opening = null;
599
+ });
600
+ return run.opening;
601
+ }
602
+ async doOpen(run) {
603
+ const oar = await this.oar();
604
+ const runtime = await this.runtime(run.runtimeId);
605
+ const installation = await runtime.installation?.();
606
+ if (!installation || installation.kind !== "available")
607
+ throw new Error(`${run.runtimeId} is not installed here (${installation?.kind ?? "no installation probe"})`);
608
+ const served = await this.serve(run);
609
+ let session;
610
+ const previous = run.agent.resumeSessionId;
611
+ try {
612
+ const options = await this.sessionOptions(run, served);
613
+ session = previous
614
+ ? await runtime
615
+ .session(installation, { ...options, resume: previous })
616
+ .catch((error) => {
617
+ if (error instanceof oar.SessionNotFoundError ||
618
+ error instanceof oar.SessionUnavailableError) {
619
+ this.log(`${run.agent.name}: can't resume ${previous} (${error.message}); starting a new session`);
620
+ return runtime.session(installation, options);
621
+ }
622
+ throw error;
623
+ })
624
+ : await runtime.session(installation, options);
625
+ }
626
+ catch (error) {
627
+ await served.close();
628
+ throw error;
629
+ }
630
+ return this.attach(run, session, served, previous);
631
+ }
632
+ /** Follow a new session: records, events, the agent's resumeSessionId. */
633
+ attach(run, session, served, previous) {
634
+ const live = {
635
+ session,
636
+ served,
637
+ owners: run.pending.splice(0),
638
+ calls: new Map(),
639
+ unsubscribe: () => { },
640
+ records: null,
641
+ draining: false,
642
+ };
643
+ const recordsDir = join(this.config.workdir, "records", run.agent.id);
644
+ const recordsPath = join(recordsDir, `${session.id}-${new Date(this.now()).toISOString().replace(/[:.]/g, "-")}.jsonl`);
645
+ const pending = [];
646
+ mkdir(recordsDir, { recursive: true })
647
+ .then(() => {
648
+ if (run.live !== live)
649
+ return;
650
+ live.records = createWriteStream(recordsPath, { flags: "a" });
651
+ live.records.on("error", () => { });
652
+ for (const record of pending.splice(0))
653
+ live.records.write(`${JSON.stringify(record)}\n`);
654
+ })
655
+ .catch(() => { });
656
+ const offRaw = session.rawEvents((record) => {
657
+ if (live.records)
658
+ live.records.write(`${JSON.stringify(record)}\n`);
659
+ else
660
+ pending.push(record);
661
+ });
662
+ const offEvents = session.events((event) => this.onEvent(run, live, event));
663
+ live.unsubscribe = () => {
664
+ offRaw();
665
+ offEvents();
666
+ };
667
+ run.live = live;
668
+ this.log(`${run.agent.name}: ${session.id === previous ? "resumed" : "opened"} ${run.runtimeId} session ${session.id} (records ${recordsPath})`);
669
+ if (session.id !== previous && this.client) {
670
+ this.client
671
+ .upsertAgent({ id: run.agent.id, resumeSessionId: session.id })
672
+ .then((agent) => {
673
+ run.agent = agent;
674
+ })
675
+ .catch((error) => {
676
+ this.log(`${run.agent.name}: saving resumeSessionId failed (${error.message})`);
677
+ });
678
+ }
679
+ return live;
680
+ }
681
+ // ---- sub-agents ----
682
+ async subagents() {
683
+ this.crew ??= this.loadAgents().then((m) => {
684
+ const crew = m.createSubagents({
685
+ runtimes: { get: (id) => this.crewRuntimes.get(id) },
686
+ maxRunning: maxRunningChildren,
687
+ maxDepth: 1,
688
+ cwd: join(this.config.workdir, "agents"),
689
+ });
690
+ crew.onReport((report) => this.onReport(report));
691
+ return crew;
692
+ });
693
+ return this.crew;
694
+ }
695
+ /**
696
+ * The runtime as the crew sees it: its sessions get the child's prompt, tools and
697
+ * limits (the crew passes only cwd, model and env) and are followed from the start.
698
+ */
699
+ async crewRuntime(id) {
700
+ const known = this.crewRuntimes.get(id);
701
+ if (known)
702
+ return known;
703
+ const runtime = await this.runtime(id);
704
+ const wrapped = Object.create(runtime);
705
+ Object.defineProperty(wrapped, "session", {
706
+ value: async (installation, options) => {
707
+ const spawning = this.spawning.get(options.cwd ?? "");
708
+ if (!spawning)
709
+ throw new Error(`no sub-agent is being opened in ${options.cwd}`);
710
+ const session = await runtime.session(installation, {
711
+ ...options,
712
+ ...spawning.options,
713
+ env: { ...options.env, ...spawning.options.env },
714
+ });
715
+ this.attach(spawning.run, session, spawning.served, spawning.run.agent.resumeSessionId);
716
+ return session;
717
+ },
718
+ });
719
+ this.crewRuntimes.set(id, wrapped);
720
+ return wrapped;
721
+ }
722
+ /** Open a child's session through the crew with `task` as its first turn; a refusal's reason, or null. */
723
+ async spawnChild(run, task) {
724
+ const crew = await this.subagents();
725
+ await this.crewRuntime(run.runtimeId);
726
+ const served = await this.serve(run);
727
+ const options = await this.sessionOptions(run, served);
728
+ const spawn = async (resume) => {
729
+ this.spawning.set(run.dir, { run, options, served });
730
+ try {
731
+ return await crew.spawn({
732
+ runtime: run.runtimeId,
733
+ task,
734
+ name: run.agent.id,
735
+ cwd: run.dir,
736
+ ...(run.model ? { model: run.model } : {}),
737
+ ...(resume ? { resume } : {}),
738
+ });
739
+ }
740
+ finally {
741
+ this.spawning.delete(run.dir);
742
+ }
743
+ };
744
+ const previous = run.agent.resumeSessionId;
745
+ let result = await spawn(previous);
746
+ if (result.kind === "refused" &&
747
+ previous &&
748
+ result.code === "open_failed") {
749
+ this.log(`${run.agent.name}: can't resume ${previous} (${result.reason}); starting a new session`);
750
+ result = await spawn(null);
751
+ }
752
+ if (result.kind === "refused") {
753
+ if (run.live)
754
+ await this.close(run);
755
+ else
756
+ await served.close().catch(() => { });
757
+ return result.reason;
758
+ }
759
+ run.crewId = result.agent.id;
760
+ return null;
761
+ }
762
+ children(parent) {
763
+ return [...parent.children.values()];
764
+ }
765
+ childOf(parent, ref) {
766
+ const child = parent.child(ref);
767
+ if (!child)
768
+ throw new Error(`没有叫「${ref}」的子 agent。你开过的:${this.children(parent)
769
+ .map((c) => c.agent.name)
770
+ .join("、") || "无"}`);
771
+ return child;
772
+ }
773
+ /** Where a tool call of the parent comes from: its current turn's conversation. */
774
+ currentConversation(parent) {
775
+ return parent.live?.owners[0]?.conversationId ?? null;
776
+ }
777
+ async spawnTool(parent, input) {
778
+ const context = this.context;
779
+ const client = this.client;
780
+ if (!context?.openTurn || !context.addAgent || !client || !this.tokens)
781
+ throw new Error("这个 host 不能开子 agent(Center 太旧)。");
782
+ const open = this.children(parent).filter((c) => c.agent.status !== "offline");
783
+ if (open.length >= maxChildren)
784
+ throw new Error(`已经开了 ${open.length} 个子 agent;先用 agent_close 关掉做完的。`);
785
+ const asked = input.writeScopes ?? [];
786
+ const denied = asked.filter((s) => !parent.scopes.includes(s));
787
+ if (denied.length)
788
+ throw new Error(`不能给子 agent ${denied.join(", ")}:你自己只有 ${parent.scopes.join(", ") || "只读"}。`);
789
+ const name = clip(input.name, 40);
790
+ const agent = await client.upsertAgent({
791
+ name,
792
+ role: clip(`「${parent.agent.name}」的子 agent:${input.task}`, 280),
793
+ hostId: context.hostId,
794
+ runtime: parent.runtimeId,
795
+ model: parent.model ?? null,
796
+ parentAgentId: parent.agent.id,
797
+ status: "idle",
798
+ });
799
+ const child = await this.prepare(agent, this.childSpec(parent, name), asked, parent);
800
+ context.addAgent(agent);
801
+ const from = this.currentConversation(parent);
802
+ const id = randomUUID();
803
+ this.opened.set(id, {
804
+ text: input.task,
805
+ origin: { kind: "notification", source: parent.agent.name },
806
+ cards: [],
807
+ });
808
+ const item = await context.openTurn({
809
+ id,
810
+ agentId: agent.id,
811
+ title: name,
812
+ text: input.task,
813
+ context: from
814
+ ? {
815
+ kind: { type: "entity", uri: conversationUri(from) },
816
+ label: `「${parent.agent.name}」交给的任务`,
817
+ symbol: "person.2",
818
+ }
819
+ : null,
820
+ });
821
+ child.conversationId = item.conversation.id;
822
+ const owner = parent.live?.owners[0];
823
+ if (owner)
824
+ this.emit(owner, {
825
+ type: "card",
826
+ card: this.childCard(child, item.conversation.id, "开始了", input.task),
827
+ });
828
+ return JSON.stringify({
829
+ agent: name,
830
+ id: agent.id,
831
+ started: true,
832
+ note: "它在后台做;做完后报告会自动送回给你,或者用 agent_wait 等。",
833
+ });
834
+ }
835
+ async sendTool(parent, input) {
836
+ const child = this.childOf(parent, input.agent);
837
+ const context = this.context;
838
+ if (!context?.openTurn)
839
+ throw new Error("这个 host 不能给子 agent 发消息。");
840
+ const from = this.currentConversation(parent);
841
+ const id = randomUUID();
842
+ this.opened.set(id, {
843
+ text: input.message,
844
+ origin: { kind: "notification", source: parent.agent.name },
845
+ cards: [],
846
+ });
847
+ try {
848
+ await context.openTurn({
849
+ id,
850
+ agentId: child.agent.id,
851
+ ...(child.conversationId
852
+ ? { conversationId: child.conversationId }
853
+ : {}),
854
+ title: child.agent.name,
855
+ text: input.message,
856
+ context: from
857
+ ? {
858
+ kind: { type: "entity", uri: conversationUri(from) },
859
+ label: `「${parent.agent.name}」发来的消息`,
860
+ symbol: "person.2",
861
+ }
862
+ : null,
863
+ });
864
+ }
865
+ catch (error) {
866
+ this.opened.delete(id);
867
+ if (error instanceof CoreError && error.status === 409)
868
+ throw new Error(`「${child.agent.name}」正在忙(在做上一件事或在和用户聊);先 agent_wait 等它报告再发。`);
869
+ throw error;
870
+ }
871
+ return JSON.stringify({ agent: child.agent.name, sent: true });
872
+ }
873
+ async waitTool(parent, input, signal) {
874
+ const ids = input.agents
875
+ ? new Set(input.agents.map((ref) => this.childOf(parent, ref).agent.id))
876
+ : null;
877
+ const wanted = (d) => !ids || ids.has(d.child.agent.id);
878
+ let got = parent.inbox.filter(wanted);
879
+ parent.inbox = parent.inbox.filter((d) => !wanted(d));
880
+ if (!got.length && input.timeoutSeconds > 0) {
881
+ const delivery = await new Promise((resolve) => {
882
+ const waiter = {
883
+ ids,
884
+ resolve: (d) => done(d),
885
+ };
886
+ const timer = setTimeout(() => done(null), input.timeoutSeconds * 1000);
887
+ const onAbort = () => done(null);
888
+ function done(d) {
889
+ clearTimeout(timer);
890
+ parent.waiters.delete(waiter);
891
+ signal.removeEventListener("abort", onAbort);
892
+ resolve(d);
893
+ }
894
+ parent.waiters.add(waiter);
895
+ signal.addEventListener("abort", onAbort, { once: true });
896
+ });
897
+ if (delivery)
898
+ got = [delivery];
899
+ }
900
+ const owner = parent.live?.owners[0];
901
+ for (const d of got)
902
+ if (owner)
903
+ this.emit(owner, {
904
+ type: "card",
905
+ card: this.reportCard(d),
906
+ });
907
+ const busy = this.children(parent)
908
+ .filter((c) => (!ids || ids.has(c.agent.id)) && c.busy > 0)
909
+ .map((c) => c.agent.name);
910
+ return JSON.stringify({
911
+ reports: got.map((d) => ({
912
+ agent: d.child.agent.name,
913
+ outcome: d.report.outcome.kind,
914
+ ...(d.report.outcome.kind === "failed"
915
+ ? { reason: d.report.outcome.reason }
916
+ : {}),
917
+ text: d.report.text,
918
+ })),
919
+ ...(busy.length ? { stillWorking: busy } : {}),
920
+ });
921
+ }
922
+ async closeTool(parent, input) {
923
+ const child = this.childOf(parent, input.agent);
924
+ await this.close(child);
925
+ parent.inbox = parent.inbox.filter((d) => d.child !== child);
926
+ if (this.client)
927
+ child.agent = await this.client
928
+ .upsertAgent({ id: child.agent.id, status: "offline" })
929
+ .catch(() => child.agent);
930
+ return JSON.stringify({ agent: child.agent.name, closed: true });
931
+ }
932
+ childCard(child, conversationId, verb, summary) {
933
+ return {
934
+ type: "record",
935
+ record: {
936
+ uri: conversationUri(conversationId),
937
+ preview: {
938
+ title: `子 agent「${child.agent.name}」${verb}`,
939
+ summary: clip(summary.trim(), 160) || null,
940
+ symbol: "person.2",
941
+ },
942
+ },
943
+ };
944
+ }
945
+ reportCard(d) {
946
+ const outcome = d.report.outcome;
947
+ return this.childCard(d.child, d.conversationId, outcome.kind === "completed"
948
+ ? "完成了"
949
+ : outcome.kind === "aborted"
950
+ ? "停下了"
951
+ : "没做完", outcome.kind === "failed" && !d.report.text
952
+ ? outcome.reason
953
+ : d.report.text);
954
+ }
955
+ /** A child ended a turn: back to the parent, if the parent started it. */
956
+ onReport(report) {
957
+ const child = [...this.runs.values()].find((r) => r.crewId === report.id);
958
+ const owner = child?.lastEnded;
959
+ if (!child?.parent || !owner)
960
+ return;
961
+ child.lastEnded = null;
962
+ if (!owner.fromParent || report.outcome.kind === "aborted")
963
+ return;
964
+ const parent = child.parent;
965
+ const delivery = {
966
+ child,
967
+ report,
968
+ conversationId: owner.conversationId,
969
+ parentConversationId: owner.fromParent,
970
+ };
971
+ const waiter = [...parent.waiters].find((w) => !w.ids || w.ids.has(child.agent.id));
972
+ if (waiter) {
973
+ waiter.resolve(delivery);
974
+ return;
975
+ }
976
+ parent.inbox.push(delivery);
977
+ this.flushInbox(parent);
978
+ }
979
+ /** An idle parent gets its undelivered reports as a turn in the conversation they belong to. */
980
+ flushInbox(parent) {
981
+ if (!parent.inbox.length || parent.busy || parent.live?.owners.length)
982
+ return;
983
+ const context = this.context;
984
+ if (!context?.openTurn)
985
+ return;
986
+ const first = parent.inbox[0];
987
+ const batch = parent.inbox.filter((d) => d.parentConversationId === first.parentConversationId);
988
+ parent.inbox = parent.inbox.filter((d) => !batch.includes(d));
989
+ const id = randomUUID();
990
+ const names = [...new Set(batch.map((d) => d.child.agent.name))];
991
+ const stored = clip(batch
992
+ .map((d) => `子 agent「${d.child.agent.name}」的报告:\n${d.report.text || (d.report.outcome.kind === "failed" ? d.report.outcome.reason : "(没有文字)")}`)
993
+ .join("\n\n"), 19_000);
994
+ const instruction = "(这是你开的子 agent 交回的报告,不是用户说的话。用一两句话告诉用户结果;需要的话再整理或继续安排。)";
995
+ this.opened.set(id, {
996
+ text: `${stored}\n\n${instruction}`,
997
+ origin: {
998
+ kind: "notification",
999
+ source: `子 agent ${names.join("、")}`,
1000
+ },
1001
+ cards: batch.map((d) => this.reportCard(d)),
1002
+ });
1003
+ context
1004
+ .openTurn({
1005
+ id,
1006
+ agentId: parent.agent.id,
1007
+ ...(first.parentConversationId
1008
+ ? { conversationId: first.parentConversationId }
1009
+ : {}),
1010
+ text: stored,
1011
+ context: {
1012
+ kind: { type: "entity", uri: conversationUri(first.conversationId) },
1013
+ label: `子 agent「${names.join("、")}」的报告`,
1014
+ symbol: "person.2",
1015
+ },
1016
+ // Not the owner's words: the app shows no bubble for it (the reply's card carries it).
1017
+ origin: "notification",
1018
+ })
1019
+ .catch((error) => {
1020
+ this.opened.delete(id);
1021
+ // Busy (a user's turn got there first): after that turn.
1022
+ if (error instanceof CoreError && error.status === 409)
1023
+ parent.inbox.unshift(...batch);
1024
+ else
1025
+ this.log(`${parent.agent.name}: delivering a report failed (${error.message})`);
1026
+ });
1027
+ }
1028
+ scheduleIdle(run) {
1029
+ if (run.idle)
1030
+ clearTimeout(run.idle);
1031
+ run.idle = setTimeout(() => {
1032
+ if (run.live && !run.live.owners.length) {
1033
+ this.log(`${run.agent.name}: idle; closing the session`);
1034
+ this.close(run);
1035
+ }
1036
+ }, this.config.idleMinutes * 60_000);
1037
+ run.idle.unref?.();
1038
+ }
1039
+ async close(run) {
1040
+ if (run.idle)
1041
+ clearTimeout(run.idle);
1042
+ run.idle = null;
1043
+ const live = run.live;
1044
+ if (!live)
1045
+ return;
1046
+ run.live = null;
1047
+ run.direct = new Set();
1048
+ run.inlined = new Set();
1049
+ run.memorySeen = null;
1050
+ for (const owner of [...live.owners])
1051
+ this.finish(live.owners, owner, null);
1052
+ live.unsubscribe();
1053
+ const crewId = run.crewId;
1054
+ run.crewId = null;
1055
+ const child = crewId
1056
+ ? (await this.crew?.catch(() => null))?.get(crewId)
1057
+ : null;
1058
+ if (child)
1059
+ await child.close().catch(() => { });
1060
+ else
1061
+ await live.session.dispose().catch(() => { });
1062
+ await live.served.close().catch(() => { });
1063
+ await new Promise((resolve) => live.records ? live.records.end(resolve) : resolve());
1064
+ }
1065
+ /** Owner stopped (or host shutdown): abort its oar turn or withdraw its queued input. */
1066
+ abortOwner(run, live, owner) {
1067
+ if (owner.finished)
1068
+ return;
1069
+ const current = live.owners[0] === owner;
1070
+ this.finish(live.owners, owner, null);
1071
+ if (current) {
1072
+ live.draining = true;
1073
+ live.session.abort().catch((error) => {
1074
+ live.draining = false;
1075
+ this.log(`${run.agent.name}: abort failed (${error.message})`);
1076
+ });
1077
+ }
1078
+ else
1079
+ live.session.withdraw?.(owner.turnId).catch(() => { });
1080
+ }
1081
+ /**
1082
+ * Emit in order behind the owner's chain: a write's record cards are looked up
1083
+ * asynchronously and must land before the text that follows the tool.
1084
+ */
1085
+ emit(owner, event) {
1086
+ owner.chain = owner.chain.then(() => owner.sink.emit(event));
1087
+ }
1088
+ /** End a core turn: emit its terminal event (after pending cards), drop it. */
1089
+ finish(owners, owner, terminal) {
1090
+ if (owner.finished)
1091
+ return;
1092
+ owner.finished = true;
1093
+ const index = owners.indexOf(owner);
1094
+ if (index >= 0)
1095
+ owners.splice(index, 1);
1096
+ owner.chain.then(() => {
1097
+ if (terminal)
1098
+ owner.sink.emit(terminal);
1099
+ owner.resolve();
1100
+ });
1101
+ }
1102
+ announce(owner, id, call) {
1103
+ const bare = bareToolName(call.raw);
1104
+ const input = parse(call.input);
1105
+ let event;
1106
+ if (bare === "tool_call" && typeof input?.name === "string") {
1107
+ call.inner = input.name;
1108
+ const inner = input.input;
1109
+ event = {
1110
+ type: "toolStarted",
1111
+ id,
1112
+ name: input.name,
1113
+ title: coreToolTitle(input.name),
1114
+ detail: inputDetail(inner),
1115
+ input: inner ?? {},
1116
+ };
1117
+ }
1118
+ else {
1119
+ if (bare !== call.raw || bare.includes("_"))
1120
+ call.inner = bare;
1121
+ event = {
1122
+ type: "toolStarted",
1123
+ id,
1124
+ name: bare,
1125
+ title: bare === "tool_describe" && typeof input?.name === "string"
1126
+ ? `查看「${coreToolTitle(input.name)}」的参数`
1127
+ : toolTitle(call.raw, input),
1128
+ detail: bare === "tool_describe" ? null : inputDetail(input ?? call.input),
1129
+ input: input ?? (call.input ? { text: call.input } : {}),
1130
+ };
1131
+ }
1132
+ call.announced = owner;
1133
+ this.emit(owner, event);
1134
+ }
1135
+ onEvent(run, live, event) {
1136
+ if (event.agentPath.length)
1137
+ return; // the runtime's own sub-agents (Task is off)
1138
+ if (event.kind === "exited") {
1139
+ for (const owner of [...live.owners])
1140
+ this.finish(live.owners, owner, {
1141
+ type: "failed",
1142
+ reason: `${run.runtimeId} 退出了${event.reason ? `:${event.reason}` : ""}`,
1143
+ });
1144
+ if (run.live === live)
1145
+ this.close(run);
1146
+ return;
1147
+ }
1148
+ // A child's report follows its turn_ended: whose turn it was.
1149
+ if (event.kind === "turn_ended")
1150
+ run.lastEnded = live.draining ? null : (live.owners[0] ?? null);
1151
+ if (live.draining) {
1152
+ if (event.kind === "turn_ended") {
1153
+ live.draining = false;
1154
+ live.calls.clear();
1155
+ }
1156
+ return;
1157
+ }
1158
+ const owner = live.owners[0];
1159
+ if (!owner)
1160
+ return;
1161
+ switch (event.kind) {
1162
+ case "text_delta":
1163
+ if (event.text)
1164
+ this.emit(owner, { type: "textDelta", text: event.text });
1165
+ return;
1166
+ case "reasoning":
1167
+ if (event.content.kind === "text" && event.content.text)
1168
+ this.emit(owner, { type: "thinking", text: event.content.text });
1169
+ return;
1170
+ case "tool_call_started": {
1171
+ const call = {
1172
+ raw: event.tool,
1173
+ input: event.input,
1174
+ announced: null,
1175
+ inner: null,
1176
+ };
1177
+ live.calls.set(event.callId, call);
1178
+ if (parse(event.input))
1179
+ this.announce(owner, event.callId, call);
1180
+ return;
1181
+ }
1182
+ case "tool_call_input": {
1183
+ const call = live.calls.get(event.callId);
1184
+ if (!call)
1185
+ return;
1186
+ call.input = event.input;
1187
+ if (!call.announced)
1188
+ this.announce(owner, event.callId, call);
1189
+ return;
1190
+ }
1191
+ case "tool_call_ended": {
1192
+ const call = live.calls.get(event.callId) ?? {
1193
+ raw: "tool",
1194
+ input: undefined,
1195
+ announced: null,
1196
+ inner: null,
1197
+ };
1198
+ live.calls.delete(event.callId);
1199
+ // Started under a turn that handed over: announce it again in this one.
1200
+ if (call.announced !== owner)
1201
+ this.announce(owner, event.callId, call);
1202
+ const text = (event.content ?? [])
1203
+ .map((part) => (part.type === "text" ? part.text : ""))
1204
+ .join("\n");
1205
+ const failed = event.result === "failed";
1206
+ this.emit(owner, {
1207
+ type: "toolEnded",
1208
+ id: event.callId,
1209
+ ...(failed ? { failed: true } : {}),
1210
+ resultSummary: text ? clip(text, 500) : null,
1211
+ });
1212
+ if (!failed && text.includes('"commandId"'))
1213
+ owner.chain = owner.chain
1214
+ .then(() => recordCards(run.tools.api, text))
1215
+ .then((cards) => {
1216
+ for (const card of cards)
1217
+ owner.sink.emit({ type: "card", card });
1218
+ })
1219
+ .catch((error) => this.log(`${run.agent.name}: record card lookup failed (${error.message})`));
1220
+ return;
1221
+ }
1222
+ case "turn_ended": {
1223
+ const outcome = event.outcome;
1224
+ if (outcome.kind === "completed")
1225
+ this.finish(live.owners, owner, { type: "done" });
1226
+ else if (outcome.kind === "aborted")
1227
+ this.finish(live.owners, owner, null);
1228
+ else
1229
+ this.finish(live.owners, owner, {
1230
+ type: "failed",
1231
+ reason: outcome.reason || `${outcome.failure}`,
1232
+ });
1233
+ live.calls.clear();
1234
+ return;
1235
+ }
1236
+ case "control_rejected":
1237
+ if (["prompt", "steer", "queue"].includes(event.action))
1238
+ this.finish(live.owners, owner, {
1239
+ type: "failed",
1240
+ reason: `${run.runtimeId}:${event.reason}`,
1241
+ });
1242
+ return;
1243
+ }
1244
+ }
1245
+ }