@llblab/pi-actors 0.20.2 → 0.21.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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { basename, dirname, join, relative, resolve } from "node:path";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as AsyncRuns from "./async-runs.ts";
11
11
  import * as Paths from "./paths.ts";
@@ -52,9 +52,25 @@ export interface RunSummary {
52
52
 
53
53
  export interface RunRetirementCandidate {
54
54
  activeSubagents: number;
55
+ childRuns: number;
55
56
  descendantSubagents: number;
56
57
  run: string;
57
58
  stateDir: string;
59
+ terminalChildRuns: number;
60
+ }
61
+
62
+ export interface RunRetirementExecution {
63
+ action: "stop" | "cancel" | "skip" | "failed";
64
+ error?: string;
65
+ run: string;
66
+ stateDir: string;
67
+ }
68
+
69
+ export interface RunRetirementExecutorOptions {
70
+ attempted?: Set<string>;
71
+ cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
72
+ notify?: (message: string, level: "info" | "warning" | "error") => void;
73
+ sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
58
74
  }
59
75
 
60
76
  export interface RunTransition {
@@ -93,6 +109,7 @@ const TERMINAL = new Set<RunObservedStatus>([
93
109
  "killed",
94
110
  ]);
95
111
  const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
112
+ const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
96
113
 
97
114
  const procDescendantScanCache = new Map<
98
115
  string,
@@ -120,6 +137,30 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
120
137
  : undefined;
121
138
  }
122
139
 
140
+ function listRunStateDirs(
141
+ stateRoot: string,
142
+ depth = 0,
143
+ seen = new Set<string>(),
144
+ ): string[] {
145
+ if (!existsSync(stateRoot) || seen.has(stateRoot)) return [];
146
+ seen.add(stateRoot);
147
+ let entries: import("node:fs").Dirent[];
148
+ try {
149
+ entries = readdirSync(stateRoot, { withFileTypes: true });
150
+ } catch {
151
+ return [];
152
+ }
153
+ const result: string[] = [];
154
+ for (const entry of entries) {
155
+ if (!entry.isDirectory()) continue;
156
+ const child = join(stateRoot, entry.name);
157
+ if (existsSync(join(child, "run.json"))) result.push(child);
158
+ if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
159
+ result.push(...listRunStateDirs(child, depth + 1, seen));
160
+ }
161
+ return result;
162
+ }
163
+
123
164
  function observeRun(stateDir: string): RunObservation | undefined {
124
165
  try {
125
166
  const status = AsyncRuns.getRunStatus(stateDir);
@@ -178,9 +219,8 @@ export function summarizeRuns(
178
219
  total: 0,
179
220
  };
180
221
  }
181
- const runs = readdirSync(stateRoot, { withFileTypes: true })
182
- .filter((entry) => entry.isDirectory())
183
- .map((entry) => observeRun(join(stateRoot, entry.name)))
222
+ const runs = listRunStateDirs(stateRoot)
223
+ .map((stateDir) => observeRun(stateDir))
184
224
  .filter((run): run is RunObservation => Boolean(run))
185
225
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
186
226
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -242,7 +282,7 @@ function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, s
242
282
  const pids = new Map<string, string>();
243
283
  for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
244
284
  if (run.status !== "running") continue;
245
- const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
285
+ const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
246
286
  const pid = Number(status.pid || 0);
247
287
  if (pid > 0) pids.set(String(pid), run.run);
248
288
  }
@@ -264,9 +304,8 @@ function summarizeRunsWithoutSubagents(
264
304
  runs: [],
265
305
  total: 0,
266
306
  };
267
- const runs = readdirSync(stateRoot, { withFileTypes: true })
268
- .filter((entry) => entry.isDirectory())
269
- .map((entry) => observeRun(join(stateRoot, entry.name)))
307
+ const runs = listRunStateDirs(stateRoot)
308
+ .map((stateDir) => observeRun(stateDir))
270
309
  .filter((run): run is RunObservation => Boolean(run))
271
310
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
272
311
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -370,28 +409,102 @@ export function renderRunStatus(
370
409
  return renderSubagentStatus(summary.runningSubagents, frame);
371
410
  }
372
411
 
412
+ function isNestedStateDir(parent: string, child: string): boolean {
413
+ const path = relative(parent, child);
414
+ return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
415
+ }
416
+
373
417
  export function findRunRetirementCandidates(
374
418
  summary: RunSummary,
375
419
  ): RunRetirementCandidate[] {
376
420
  return summary.runs
377
- .filter((run) => {
421
+ .map((run) => {
378
422
  const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
379
423
  const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
380
- return (
381
- run.status === "running" &&
382
- run.retireWhen === "children_terminal" &&
383
- run.stateDir &&
384
- activeSubagents + descendantSubagents <= 0
385
- );
424
+ const childRuns = run.stateDir
425
+ ? summary.runs.filter(
426
+ (child) =>
427
+ child.stateDir !== undefined &&
428
+ child.stateDir !== run.stateDir &&
429
+ isNestedStateDir(run.stateDir!, child.stateDir),
430
+ )
431
+ : [];
432
+ const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
433
+ return {
434
+ activeSubagents,
435
+ childRuns: childRuns.length,
436
+ descendantSubagents,
437
+ ready:
438
+ run.status === "running" &&
439
+ run.retireWhen === "children_terminal" &&
440
+ run.stateDir !== undefined &&
441
+ !run.terminalHandled &&
442
+ activeSubagents + descendantSubagents + runningChildRuns <= 0,
443
+ run,
444
+ terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
445
+ };
386
446
  })
387
- .map((run) => ({
388
- activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
389
- descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
390
- run: run.run,
391
- stateDir: run.stateDir!,
447
+ .filter((item) => item.ready)
448
+ .map((item) => ({
449
+ activeSubagents: item.activeSubagents,
450
+ childRuns: item.childRuns,
451
+ descendantSubagents: item.descendantSubagents,
452
+ run: item.run.run,
453
+ stateDir: item.run.stateDir!,
454
+ terminalChildRuns: item.terminalChildRuns,
392
455
  }));
393
456
  }
394
457
 
458
+ export async function executeRunRetirements(
459
+ summary: RunSummary,
460
+ options: RunRetirementExecutorOptions,
461
+ ): Promise<RunRetirementExecution[]> {
462
+ const results: RunRetirementExecution[] = [];
463
+ for (const candidate of findRunRetirementCandidates(summary)) {
464
+ if (options.attempted?.has(candidate.stateDir)) {
465
+ results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
466
+ continue;
467
+ }
468
+ options.attempted?.add(candidate.stateDir);
469
+ try {
470
+ await options.sendStop(candidate);
471
+ options.notify?.(
472
+ `Retiring actor ${candidate.run} after child runs reached terminal state`,
473
+ "info",
474
+ );
475
+ results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
476
+ continue;
477
+ } catch (error) {
478
+ try {
479
+ const cancelResult = options.cancelRun(candidate);
480
+ const cancelled = Boolean((cancelResult as { cancelled?: unknown }).cancelled);
481
+ options.notify?.(
482
+ cancelled
483
+ ? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
484
+ : `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`,
485
+ cancelled ? "warning" : "error",
486
+ );
487
+ results.push({
488
+ action: cancelled ? "cancel" : "skip",
489
+ ...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
490
+ run: candidate.run,
491
+ stateDir: candidate.stateDir,
492
+ });
493
+ } catch (cancelError) {
494
+ const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
495
+ options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
496
+ results.push({
497
+ action: "failed",
498
+ error: message,
499
+ run: candidate.run,
500
+ stateDir: candidate.stateDir,
501
+ });
502
+ }
503
+ }
504
+ }
505
+ return results;
506
+ }
507
+
395
508
  export function detectRunTransitions(
396
509
  previous: Map<string, RunObservedStatus>,
397
510
  summary: RunSummary,
@@ -88,11 +88,11 @@ function listRecipeFiles(root: string): string[] {
88
88
  .filter(
89
89
  (entry) =>
90
90
  entry.isFile() &&
91
- entry.name.endsWith(".json") &&
91
+ (entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
92
92
  entry.name !== "legacy-tool-registry-migration-report.json",
93
93
  )
94
94
  .map((entry) => join(root, entry.name))
95
- .sort();
95
+ .sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
96
96
  }
97
97
 
98
98
  function getRecipeConfigDiagnostics(
@@ -100,9 +100,15 @@ function getRecipeConfigDiagnostics(
100
100
  config: TemplateRecipeConfig | undefined,
101
101
  ): string[] {
102
102
  if (!config) return [`Invalid recipe: ${file}`];
103
+ const commandTemplateConfig =
104
+ typeof config.template === "object" && config.template !== null
105
+ ? config.template
106
+ : config;
103
107
  return CommandTemplates.getCommandTemplateWarnings(
104
- typeof config.template === "string" ? config.template : { template: config.template },
105
- ).map((warning) => `Recipe ${file}: ${warning}`);
108
+ commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
109
+ ).map(
110
+ (warning) => `Recipe ${file}: ${warning}`,
111
+ );
106
112
  }
107
113
 
108
114
  function readDiscoveredRecipe(
@@ -218,13 +224,22 @@ export function discoverRecipeSources(
218
224
  const diagnostics: string[] = getRecipeRootDiagnostics(sources);
219
225
  for (const [id, bucket] of byId) {
220
226
  bucket.sort(
221
- (a, b) => a.priority - b.priority || a.path.localeCompare(b.path),
227
+ (a, b) =>
228
+ a.priority - b.priority ||
229
+ a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
230
+ (a.path.endsWith(".json") ? -1 : 1),
222
231
  );
223
232
  const winner = bucket[0];
224
233
  winner.active = true;
225
234
  winner.shadows = bucket.slice(1).map((entry) => entry.path);
226
235
  active.set(id, winner);
227
- for (const shadow of bucket.slice(1)) shadow.shadowed = true;
236
+ for (const shadow of bucket.slice(1)) {
237
+ shadow.shadowed = true;
238
+ if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
239
+ shadow.diagnostics.push(
240
+ `Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`,
241
+ );
242
+ }
228
243
  if (winner.invalid)
229
244
  diagnostics.push(
230
245
  `Recipe ${id} is invalid and blocks lower-priority recipes`,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { basename, dirname, resolve } from "node:path";
9
+ import { basename, dirname, extname, resolve } from "node:path";
10
10
 
11
11
  import type {
12
12
  CommandTemplateConfig,
@@ -101,7 +101,9 @@ export function resolveRecipePath(
101
101
  if (expanded.includes("/")) return resolve(expanded);
102
102
  return resolve(
103
103
  recipeRoot,
104
- expanded.endsWith(".json") ? expanded : `${expanded}.json`,
104
+ expanded.endsWith(".json") || expanded.endsWith(".md")
105
+ ? expanded
106
+ : `${expanded}.json`,
105
107
  );
106
108
  }
107
109
 
@@ -110,20 +112,26 @@ function isBareRecipeName(value: string): boolean {
110
112
  return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
111
113
  }
112
114
 
113
- function recipeNameFile(value: string): string {
115
+ function recipeNameFiles(value: string): string[] {
114
116
  const trimmed = value.trim();
115
- return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
117
+ if (trimmed.endsWith(".json") || trimmed.endsWith(".md")) return [trimmed];
118
+ return [`${trimmed}.json`, `${trimmed}.md`];
116
119
  }
117
120
 
118
121
  function resolveRecipeImportPath(value: string, currentRecipeRoot: string): string {
119
122
  if (!isBareRecipeName(value)) return resolveRecipePath(value, currentRecipeRoot);
120
- const file = recipeNameFile(value);
121
123
  const roots = [
122
124
  Paths.getRecipeRoot(),
123
125
  currentRecipeRoot,
124
126
  Paths.getPackagedRecipeRoot(),
125
127
  ];
126
- const candidates = [...new Set(roots.map((root) => resolve(root, file)))];
128
+ const candidates = [
129
+ ...new Set(
130
+ roots.flatMap((root) =>
131
+ recipeNameFiles(value).map((file) => resolve(root, file)),
132
+ ),
133
+ ),
134
+ ];
127
135
  return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
128
136
  }
129
137
 
@@ -134,14 +142,14 @@ export function getRecipePath(
134
142
  if (typeof value !== "string") return undefined;
135
143
  const trimmed = value.trim();
136
144
  if (!trimmed || hasWhitespace(trimmed)) return undefined;
137
- if (trimmed.endsWith(".json")) return resolveRecipePath(trimmed, recipeRoot);
138
- const path = resolveRecipePath(trimmed, recipeRoot);
145
+ if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
146
+ return resolveRecipePath(trimmed, recipeRoot);
147
+ const jsonPath = resolveRecipePath(trimmed, recipeRoot);
148
+ const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
149
+ const path = existsSync(jsonPath) ? jsonPath : mdPath;
139
150
  if (!existsSync(path)) return undefined;
140
151
  try {
141
- const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
142
- string,
143
- unknown
144
- >;
152
+ const raw = readRawRecipeConfig(path);
145
153
  return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
146
154
  ? path
147
155
  : undefined;
@@ -232,6 +240,123 @@ function getRecipeCommandTemplate(
232
240
  return normalizeRecipeTemplate({ ...envelope, template });
233
241
  }
234
242
 
243
+ function parseMarkdownScalar(value: string): unknown {
244
+ const trimmed = value.trim();
245
+ if (!trimmed) return "";
246
+ if (
247
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
248
+ (trimmed.startsWith("[") && trimmed.endsWith("]"))
249
+ ) {
250
+ try {
251
+ return JSON.parse(trimmed);
252
+ } catch {
253
+ return trimmed;
254
+ }
255
+ }
256
+ const quoted = trimmed.match(/^(?:"([^"]*)"|'([^']*)')$/);
257
+ if (quoted) return quoted[1] ?? quoted[2] ?? "";
258
+ if (trimmed === "true") return true;
259
+ if (trimmed === "false") return false;
260
+ if (trimmed === "null") return null;
261
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
262
+ return trimmed;
263
+ }
264
+
265
+ function parseMarkdownFrontmatterObject(
266
+ lines: string[],
267
+ ): Record<string, unknown> | unknown[] {
268
+ if (lines.every((line) => /^\s*-\s+/.test(line))) {
269
+ return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
270
+ }
271
+ const result: Record<string, unknown> = {};
272
+ for (let index = 0; index < lines.length; index += 1) {
273
+ const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
274
+ if (!match) continue;
275
+ if (match[2]) {
276
+ result[match[1]] = parseMarkdownScalar(match[2]);
277
+ continue;
278
+ }
279
+ const nested: string[] = [];
280
+ while (index + 1 < lines.length && /^\s{4}/.test(lines[index + 1])) {
281
+ index += 1;
282
+ nested.push(lines[index].slice(2));
283
+ }
284
+ result[match[1]] = parseMarkdownFrontmatterObject(nested);
285
+ }
286
+ return result;
287
+ }
288
+
289
+ function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
290
+ const result: Record<string, unknown> = {};
291
+ const lines = value.split(/\r?\n/);
292
+ for (let index = 0; index < lines.length; index += 1) {
293
+ const line = lines[index];
294
+ if (!line.trim() || line.trim().startsWith("#")) continue;
295
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
296
+ if (!match) continue;
297
+ if (match[2]) {
298
+ result[match[1]] = parseMarkdownScalar(match[2]);
299
+ continue;
300
+ }
301
+ const nested: string[] = [];
302
+ while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
303
+ index += 1;
304
+ nested.push(lines[index]);
305
+ }
306
+ result[match[1]] = parseMarkdownFrontmatterObject(nested);
307
+ }
308
+ return result;
309
+ }
310
+
311
+ function findMarkdownRecipeFence(
312
+ body: string,
313
+ ): { info: string; body: string } | undefined {
314
+ const pattern = /```([^\n`]*)\n([\s\S]*?)```/g;
315
+ for (const match of body.matchAll(pattern)) {
316
+ const info = match[1].trim().toLowerCase();
317
+ if (
318
+ info.includes("recipe") ||
319
+ info.includes("template") ||
320
+ info.includes("command") ||
321
+ info.includes("json")
322
+ ) {
323
+ return { info, body: match[2].trim() };
324
+ }
325
+ }
326
+ return undefined;
327
+ }
328
+
329
+ function parseMarkdownRecipeConfig(
330
+ content: string,
331
+ ): Record<string, unknown> | undefined {
332
+ const lines = content.split(/\r?\n/);
333
+ if (lines[0]?.trim() !== "---") return undefined;
334
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
335
+ if (end === -1) return undefined;
336
+ const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
337
+ const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
338
+ if (!fence) return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
339
+ const text = fence.body.trim();
340
+ if (!text) return undefined;
341
+ if (
342
+ fence.info.includes("json") ||
343
+ fence.info.includes("recipe") ||
344
+ text.startsWith("{") ||
345
+ text.startsWith("[") ||
346
+ text.startsWith('"')
347
+ ) {
348
+ try {
349
+ const parsed = JSON.parse(text) as unknown;
350
+ if (isRecord(parsed) && Object.hasOwn(parsed, "template"))
351
+ return { ...frontmatter, ...parsed };
352
+ return { ...frontmatter, template: parsed };
353
+ } catch {
354
+ if (fence.info.includes("json") || fence.info.includes("recipe")) return undefined;
355
+ }
356
+ }
357
+ return { ...frontmatter, template: text };
358
+ }
359
+
235
360
  export function readRawRecipeConfig(
236
361
  path: string,
237
362
  ): Record<string, unknown> | undefined {
@@ -243,10 +368,9 @@ export function readRawRecipeConfig(
243
368
  );
244
369
  }
245
370
  try {
246
- const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
247
- string,
248
- unknown
249
- >;
371
+ const content = readFileSync(path, "utf8");
372
+ if (path.endsWith(".md")) return parseMarkdownRecipeConfig(content);
373
+ const raw = JSON.parse(content) as Record<string, unknown>;
250
374
  return raw && typeof raw === "object" ? raw : undefined;
251
375
  } catch {
252
376
  return undefined;
@@ -254,7 +378,7 @@ export function readRawRecipeConfig(
254
378
  }
255
379
 
256
380
  export function getRecipeIdFromPath(file: string): string {
257
- return basename(file, ".json");
381
+ return basename(file, extname(file));
258
382
  }
259
383
 
260
384
  function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
package/lib/tools.ts CHANGED
@@ -1089,7 +1089,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1089
1089
  } else if (message.type === "control.kill") {
1090
1090
  result = AsyncRuns.killRun(address.value);
1091
1091
  } else {
1092
- result = AsyncRuns.sendRunMessage(
1092
+ result = await AsyncRuns.sendRunMessage(
1093
1093
  address.value,
1094
1094
  messageBodyToRunLine(message),
1095
1095
  );
@@ -1131,7 +1131,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1131
1131
  ActorRooms.writeCommunicationSnapshot(stateDir, runId);
1132
1132
  ActorRooms.appendBranchInboxMessage(stateDir, runId, message.to, message);
1133
1133
  }
1134
- result = AsyncRuns.sendRunMessage(
1134
+ result = await AsyncRuns.sendRunMessage(
1135
1135
  address.value,
1136
1136
  JSON.stringify(message),
1137
1137
  );
@@ -1143,16 +1143,18 @@ export function createActorMessageToolDefinition<TContext = unknown>(
1143
1143
  if (!stateDir) throw new Error(`${message.to} has no run state directory.`);
1144
1144
  const recipients = getRoomMulticastRecipients(message, runId);
1145
1145
  const roomResult = ActorRooms.appendRoomMessage(stateDir, address.room, message);
1146
- const multicast = recipients.map((recipient) =>
1147
- AsyncRuns.sendRunMessage(
1148
- runId,
1149
- JSON.stringify({ ...message, to: recipient }),
1146
+ await Promise.all(
1147
+ recipients.map((recipient) =>
1148
+ AsyncRuns.sendRunMessage(
1149
+ runId,
1150
+ JSON.stringify({ ...message, to: recipient }),
1151
+ ),
1150
1152
  ),
1151
1153
  );
1152
1154
  result = {
1153
1155
  ...roomResult,
1154
- ...(multicast.length > 0
1155
- ? { multicast: recipients, multicast_count: multicast.length }
1156
+ ...(recipients.length > 0
1157
+ ? { multicast: recipients, multicast_count: recipients.length }
1156
1158
  : {}),
1157
1159
  };
1158
1160
  } else if (address.kind === "tool" && address.value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "async": true,
3
3
  "args": [
4
- "mode:string",
4
+ "mode:enum(consensus,pipeline,fanout,pool)",
5
5
  "mission:string",
6
6
  "model:string",
7
7
  "thinking:string",