@llblab/pi-actors 0.16.4 → 0.17.1

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/lib/tools.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as ActorMessages from "./actor-messages.ts";
8
+ import * as ActorRooms from "./actor-rooms.ts";
8
9
  import * as AsyncRuns from "./async-runs.ts";
9
10
  import * as CommandTemplates from "./command-templates.ts";
10
11
  import type { RegisteredTool } from "./config.ts";
@@ -192,6 +193,99 @@ function compactRunMessages(messages: AsyncRuns.RunOutboxEvent[]): string {
192
193
  .join("\n")}`;
193
194
  }
194
195
 
196
+ function compactPreview(value: unknown, maxLength = 80): string | undefined {
197
+ if (value === undefined) return undefined;
198
+ const text =
199
+ typeof value === "string" ? value : JSON.stringify(value, undefined, 0);
200
+ const compact = text.replaceAll(/\s+/g, "_");
201
+ return compact.length > maxLength
202
+ ? `${compact.slice(0, Math.max(0, maxLength - 1))}…`
203
+ : compact;
204
+ }
205
+
206
+ function compactRoomPreviews(previews: ActorRooms.RoomMessagePreview[]): string {
207
+ if (previews.length === 0) return "\n(no room message previews)";
208
+ return `\n${previews
209
+ .map((preview) =>
210
+ [
211
+ `ts=${preview.timestamp}`,
212
+ preview.from ? `from=${preview.from}` : "",
213
+ `to=${preview.to}`,
214
+ `type=${preview.type}`,
215
+ preview.summary ? `summary=${compactPreview(preview.summary)}` : "",
216
+ preview.body_preview ? `body=${compactPreview(preview.body_preview)}` : "",
217
+ ]
218
+ .filter(Boolean)
219
+ .join(" "),
220
+ )
221
+ .join("\n")}`;
222
+ }
223
+
224
+ function compactRoomMessages(messages: ActorRooms.RoomTimelineEntry[]): string {
225
+ if (messages.length === 0) return "\n(no room messages)";
226
+ return `\n${messages
227
+ .map((message) =>
228
+ [
229
+ `ts=${message.received_at}`,
230
+ `from=${String(message.from ?? "<unknown>")}`,
231
+ `to=${message.to}`,
232
+ `type=${message.type}`,
233
+ `summary=${String(message.summary ?? "").replaceAll(/\s+/g, "_")}`,
234
+ compactPreview(message.body) ? `body=${compactPreview(message.body)}` : "",
235
+ ]
236
+ .filter(Boolean)
237
+ .join(" "),
238
+ )
239
+ .join("\n")}`;
240
+ }
241
+
242
+ function compactRoomContacts(contacts: ActorRooms.RoomContact[]): string {
243
+ if (contacts.length === 0) return "\n(no room contacts)";
244
+ return `\n${contacts
245
+ .map((contact) =>
246
+ [
247
+ `address=${contact.address}`,
248
+ contact.role !== undefined ? `role=${String(contact.role)}` : "",
249
+ contact.parent !== undefined ? `parent=${String(contact.parent)}` : "",
250
+ contact.caps !== undefined ? `caps=${Array.isArray(contact.caps) ? contact.caps.join(",") : String(contact.caps)}` : "",
251
+ contact.claim !== undefined ? `claim=${String(contact.claim).replaceAll(/\s+/g, "_")}` : "",
252
+ contact.status !== undefined ? `status=${String(contact.status)}` : "",
253
+ ]
254
+ .filter(Boolean)
255
+ .join(" "),
256
+ )
257
+ .join("\n")}`;
258
+ }
259
+
260
+ function compactRoomRoster(roster: Record<string, ActorRooms.RoomMember>): string {
261
+ const members = Object.values(roster);
262
+ if (members.length === 0) return "\n(no room members)";
263
+ return `\n${members
264
+ .map((member) =>
265
+ [
266
+ `address=${member.address}`,
267
+ `role=${String(member.role ?? "")}`,
268
+ member.parent !== undefined ? `parent=${String(member.parent)}` : "",
269
+ member.caps !== undefined ? `caps=${Array.isArray(member.caps) ? member.caps.join(",") : String(member.caps)}` : "",
270
+ member.claim !== undefined ? `claim=${String(member.claim).replaceAll(/\s+/g, "_")}` : "",
271
+ `status=${String(member.status ?? "")}`,
272
+ `last_seen=${member.last_seen}`,
273
+ ].join(" "),
274
+ )
275
+ .join("\n")}`;
276
+ }
277
+
278
+ function compactRoomStatus(status: ActorRooms.RoomStatus): string {
279
+ return `\nroom=${status.room} messages=${status.message_count} roster=${status.roster_count}${status.last_message_at ? ` last_message_at=${status.last_message_at}` : ""}${status.last_message_from ? ` last_from=${status.last_message_from}` : ""}${status.last_message_type ? ` last_type=${status.last_message_type}` : ""}${status.last_message_summary ? ` last_summary=${compactPreview(status.last_message_summary)}` : ""}`;
280
+ }
281
+
282
+ function compactCommunicationSnapshot(
283
+ snapshot: ActorRooms.ActorCommunicationSnapshot | undefined,
284
+ ): string {
285
+ if (!snapshot) return "\n(no communication snapshot)";
286
+ return `\nself=${snapshot.self} root=${snapshot.root} rooms=${snapshot.rooms.length} updated_at=${snapshot.updated_at}`;
287
+ }
288
+
195
289
  function compactActorFiles(status: Record<string, unknown>): string {
196
290
  const run = String(status.run ?? "<unknown>");
197
291
  const artifacts = asRecord(status.artifacts);
@@ -200,6 +294,7 @@ function compactActorFiles(status: Record<string, unknown>): string {
200
294
  status.stderrLog,
201
295
  status.eventsFile,
202
296
  status.outboxFile,
297
+ status.state_dir ? `${String(status.state_dir)}/communication.json` : undefined,
203
298
  status.state_dir ? `${String(status.state_dir)}/result.json` : undefined,
204
299
  ].filter((file): file is string => typeof file === "string");
205
300
  const artifactText = Object.keys(artifacts).length
@@ -240,7 +335,10 @@ function compactRecipeRegistry(summary: Record<string, unknown>): string {
240
335
  const diagnostics = Array.isArray(summary.diagnostics)
241
336
  ? summary.diagnostics.length
242
337
  : 0;
243
- return `\nrecipes active=${active} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} diagnostics=${diagnostics}`;
338
+ const recommendations = Array.isArray(summary.recommendations)
339
+ ? summary.recommendations.length
340
+ : 0;
341
+ return `\nrecipes active=${active} shadowed=${shadowed} invalid=${invalid} disabled=${disabled} recommendations=${recommendations} diagnostics=${diagnostics}`;
244
342
  }
245
343
 
246
344
  function compactActorMessageResult(
@@ -255,6 +353,11 @@ function compactActorMessageResult(
255
353
  if (result.bytes !== undefined) tokens.push(`bytes=${String(result.bytes)}`);
256
354
  if (result.control) tokens.push(`control=${String(result.control)}`);
257
355
  if (result.outbox) tokens.push(`messages=${String(result.outbox)}`);
356
+ if (result.message_count !== undefined)
357
+ tokens.push(`messages=${String(result.message_count)}`);
358
+ if (result.roster_count !== undefined)
359
+ tokens.push(`roster=${String(result.roster_count)}`);
360
+ if (result.room) tokens.push(`room=${String(result.room)}`);
258
361
  if (result.tool) tokens.push(`tool=${String(result.tool)}`);
259
362
  if (result.stopped === true) tokens.push("stopped=true");
260
363
  if (result.signal) tokens.push(`signal=${String(result.signal)}`);
@@ -352,6 +455,25 @@ function runIdFromActorAddress(
352
455
  return parsed.value;
353
456
  }
354
457
 
458
+ function assertMessageSenderBelongsToRun(
459
+ message: ActorMessages.ActorMessage,
460
+ run: string,
461
+ routeLabel: string,
462
+ ): void {
463
+ if (!message.from) {
464
+ throw new Error(`message to ${message.to} requires from=<actor address>.`);
465
+ }
466
+ const sender = ActorMessages.parseActorAddress(message.from);
467
+ if (
468
+ (sender.kind !== "run" && sender.kind !== "branch") ||
469
+ sender.value !== run
470
+ ) {
471
+ throw new Error(
472
+ `message to ${routeLabel} requires from=run:${run} or branch:${run}/<branch>; got ${message.from}.`,
473
+ );
474
+ }
475
+ }
476
+
355
477
  export function createSpawnToolDefinition<
356
478
  TContext extends AsyncRunToolContext,
357
479
  >(): any {
@@ -422,6 +544,8 @@ export function createSpawnToolDefinition<
422
544
  },
423
545
  ctx.cwd,
424
546
  );
547
+ ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
548
+ ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
425
549
  return {
426
550
  content: [
427
551
  {
@@ -475,6 +599,10 @@ function assertRunAccessibleToContext(
475
599
  return status;
476
600
  }
477
601
 
602
+ function assertRunExistsForActorMessage(runId: string): Record<string, unknown> {
603
+ return AsyncRuns.getRunStatus(runId);
604
+ }
605
+
478
606
  export function createInspectToolDefinition<TContext = unknown>(
479
607
  deps: InspectToolDeps<TContext> = {},
480
608
  ): any {
@@ -482,7 +610,7 @@ export function createInspectToolDefinition<TContext = unknown>(
482
610
  name: "inspect",
483
611
  label: "Inspect",
484
612
  description:
485
- "Intentionally inspect an actor. Supports run:<id> views: status, tail, messages, artifacts, files, mailbox; coordinator/session status; and tool:<name> status/schema.",
613
+ "Intentionally inspect an actor. Supports run:<id> views: status, tail, messages, artifacts, files, mailbox, communication; room:<run> status/messages/previews/roster/contacts; coordinator/session status; and tool:<name> status/schema.",
486
614
  parameters: objectSchema(
487
615
  {
488
616
  lines: stringSchema("Line count for tail/messages views. Default 40."),
@@ -490,13 +618,13 @@ export function createInspectToolDefinition<TContext = unknown>(
490
618
  "Optional session run filter: all, running, active, terminal, done, failed, cancelled, killed, or exited.",
491
619
  ),
492
620
  target: stringSchema(
493
- "Actor address to inspect, e.g. run:<id>, coordinator, session:<id>, session:all, or tool:<name>.",
621
+ "Actor address to inspect, e.g. run:<id>, room:<run>, coordinator, session:<id>, session:all, or tool:<name>.",
494
622
  ),
495
623
  verbose: booleanSchema(
496
624
  "Return full JSON instead of compact text where available.",
497
625
  ),
498
626
  view: stringSchema(
499
- "Inspection view: status, tail, messages, artifacts, files, or mailbox.",
627
+ "Inspection view: status, tail, messages, artifacts, files, mailbox, communication, roster, or contacts.",
500
628
  ),
501
629
  },
502
630
  ["target", "view"],
@@ -618,6 +746,100 @@ export function createInspectToolDefinition<TContext = unknown>(
618
746
  details,
619
747
  };
620
748
  }
749
+ if (address.kind === "room" && address.value && address.room) {
750
+ const status = assertRunAccessibleToContext(address.value, ctx);
751
+ const stateDir = String(status.state_dir ?? "");
752
+ if (!stateDir) throw new Error(`room:${address.value} has no run state directory.`);
753
+ if (view === "status") {
754
+ const status = ActorRooms.getRoomStatus(stateDir, address.room);
755
+ return {
756
+ content: [
757
+ {
758
+ type: "text" as const,
759
+ text: maybeJsonText(
760
+ status,
761
+ input.verbose === true,
762
+ compactRoomStatus(status),
763
+ ),
764
+ },
765
+ ],
766
+ details: status,
767
+ };
768
+ }
769
+ if (view === "previews") {
770
+ const previews = ActorRooms.readRoomMessagePreviews(
771
+ stateDir,
772
+ address.room,
773
+ Number(input.lines || 40),
774
+ );
775
+ return {
776
+ content: [
777
+ {
778
+ type: "text" as const,
779
+ text: maybeJsonText(
780
+ previews,
781
+ input.verbose === true,
782
+ compactRoomPreviews(previews),
783
+ ),
784
+ },
785
+ ],
786
+ details: { previews },
787
+ };
788
+ }
789
+ if (view === "messages") {
790
+ const messages = ActorRooms.readRoomMessages(
791
+ stateDir,
792
+ address.room,
793
+ Number(input.lines || 40),
794
+ );
795
+ return {
796
+ content: [
797
+ {
798
+ type: "text" as const,
799
+ text: maybeJsonText(
800
+ messages,
801
+ input.verbose === true,
802
+ compactRoomMessages(messages),
803
+ ),
804
+ },
805
+ ],
806
+ details: { messages },
807
+ };
808
+ }
809
+ if (view === "contacts") {
810
+ const contacts = ActorRooms.readRoomContacts(stateDir, address.room);
811
+ return {
812
+ content: [
813
+ {
814
+ type: "text" as const,
815
+ text: maybeJsonText(
816
+ contacts,
817
+ input.verbose === true,
818
+ compactRoomContacts(contacts),
819
+ ),
820
+ },
821
+ ],
822
+ details: { contacts },
823
+ };
824
+ }
825
+ if (view === "roster") {
826
+ const roster = ActorRooms.readRoomRoster(stateDir, address.room);
827
+ return {
828
+ content: [
829
+ {
830
+ type: "text" as const,
831
+ text: maybeJsonText(
832
+ roster,
833
+ input.verbose === true,
834
+ compactRoomRoster(roster),
835
+ ),
836
+ },
837
+ ],
838
+ details: { roster },
839
+ };
840
+ }
841
+ throw new Error("inspect room:<run> supports view=status, view=messages, view=previews, view=roster, or view=contacts.");
842
+ }
621
843
  const runId = address.kind === "run" ? address.value : undefined;
622
844
  if (!runId)
623
845
  throw new Error(
@@ -702,9 +924,28 @@ export function createInspectToolDefinition<TContext = unknown>(
702
924
  details: { mailbox },
703
925
  };
704
926
  }
927
+ case "communication": {
928
+ const status = assertRunAccessibleToContext(runId, ctx);
929
+ const snapshot = ActorRooms.readCommunicationSnapshot(
930
+ String(status.state_dir ?? ""),
931
+ );
932
+ return {
933
+ content: [
934
+ {
935
+ type: "text" as const,
936
+ text: maybeJsonText(
937
+ snapshot ?? {},
938
+ input.verbose === true,
939
+ compactCommunicationSnapshot(snapshot),
940
+ ),
941
+ },
942
+ ],
943
+ details: { communication: snapshot },
944
+ };
945
+ }
705
946
  default:
706
947
  throw new Error(
707
- "inspect view must be one of: status, tail, messages, artifacts, files, mailbox.",
948
+ "inspect view must be one of: status, tail, messages, artifacts, files, mailbox, communication.",
708
949
  );
709
950
  }
710
951
  },
@@ -722,7 +963,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
722
963
  name: "message",
723
964
  label: "Message",
724
965
  description:
725
- "Send one typed addressed message. Routes to run:<id> mailboxes, branch:<run>/<branch> mailboxes, tool:<name> calls, and coordinator/session-bound run messages.",
966
+ "Send one typed addressed message. Routes to run:<id> mailboxes, branch:<run>/<branch> mailboxes, room:<run> timelines/rosters, tool:<name> calls, and coordinator/session-bound run messages.",
726
967
  parameters: objectSchema(
727
968
  {
728
969
  body: unionSchema([
@@ -744,7 +985,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
744
985
  reply_to: stringSchema("Optional message id this message replies to."),
745
986
  summary: stringSchema("Optional short human-facing summary."),
746
987
  to: stringSchema(
747
- "Destination actor address, e.g. run:<id>, branch:<run>/<branch>, coordinator, session:<id>, or tool:<name>.",
988
+ "Destination actor address, e.g. run:<id>, branch:<run>/<branch>, room:<run>, coordinator, session:<id>, or tool:<name>.",
748
989
  ),
749
990
  type: stringSchema(
750
991
  "Semantic message type, e.g. control.approve or checkpoint.needs_scope.",
@@ -780,11 +1021,51 @@ export function createActorMessageToolDefinition<TContext = unknown>(
780
1021
  );
781
1022
  }
782
1023
  } else if (address.kind === "branch" && address.value) {
783
- assertRunAccessibleToContext(address.value, ctx);
1024
+ const runId = address.value;
1025
+ if (message.from) assertMessageSenderBelongsToRun(message, runId, `branch:${runId}/<branch>`);
1026
+ const status = message.from
1027
+ ? assertRunExistsForActorMessage(runId)
1028
+ : assertRunAccessibleToContext(runId, ctx);
1029
+ const stateDir = String(status.state_dir ?? "");
1030
+ if (stateDir && address.branch) {
1031
+ const ensureBranchMember = (actorAddress: string) => {
1032
+ ActorRooms.ensureRoomMember(
1033
+ stateDir,
1034
+ runId,
1035
+ "main",
1036
+ actorAddress,
1037
+ {
1038
+ parent: `run:${runId}`,
1039
+ role: "branch",
1040
+ status: "present",
1041
+ },
1042
+ "Branch joined default room",
1043
+ );
1044
+ ActorRooms.writeBranchCommunicationSnapshot(
1045
+ stateDir,
1046
+ runId,
1047
+ actorAddress,
1048
+ );
1049
+ };
1050
+ ensureBranchMember(message.to);
1051
+ if (message.from) {
1052
+ const sender = ActorMessages.parseActorAddress(message.from);
1053
+ if (sender.kind === "branch" && sender.value === runId) {
1054
+ ensureBranchMember(message.from);
1055
+ }
1056
+ }
1057
+ ActorRooms.writeCommunicationSnapshot(stateDir, runId);
1058
+ }
784
1059
  result = AsyncRuns.sendRunMessage(
785
1060
  address.value,
786
1061
  JSON.stringify(message),
787
1062
  );
1063
+ } else if (address.kind === "room" && address.value && address.room) {
1064
+ assertMessageSenderBelongsToRun(message, address.value, `room:${address.value}`);
1065
+ const status = assertRunExistsForActorMessage(address.value);
1066
+ const stateDir = String(status.state_dir ?? "");
1067
+ if (!stateDir) throw new Error(`${message.to} has no run state directory.`);
1068
+ result = { ...ActorRooms.appendRoomMessage(stateDir, address.room, message) };
788
1069
  } else if (address.kind === "tool" && address.value) {
789
1070
  const tool = deps.getTool?.(address.value);
790
1071
  if (!tool || typeof tool.execute !== "function") {
@@ -842,7 +1123,7 @@ export function createActorMessageToolDefinition<TContext = unknown>(
842
1123
  });
843
1124
  } else {
844
1125
  throw new Error(
845
- `message currently supports run:<id>, branch:<run>/<branch>, tool:<name>, coordinator, and session:<id> destinations; unsupported destination: ${message.to}`,
1126
+ `message currently supports run:<id>, branch:<run>/<branch>, room:<run>, tool:<name>, coordinator, and session:<id> destinations; unsupported destination: ${message.to}`,
846
1127
  );
847
1128
  }
848
1129
  return {
@@ -942,6 +1223,8 @@ export function createRuntimeToolDefinition(
942
1223
  },
943
1224
  ctx.cwd,
944
1225
  );
1226
+ ActorRooms.ensureDefaultRoom(meta.state_dir, String(meta.run));
1227
+ ActorRooms.writeCommunicationSnapshot(meta.state_dir, String(meta.run));
945
1228
  return {
946
1229
  content: [
947
1230
  { type: "text" as const, text: compactAsyncRunStatus(meta) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.16.4",
3
+ "version": "0.17.1",
4
4
  "private": false,
5
5
  "description": "Actor runtime and orchestrator for agent-managed local processes",
6
6
  "keywords": [
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "lens-swarm",
3
3
  "description": "General-purpose multi-lens review swarm. Launches independent reviewers by lens, then verifies, merges, judges, and normalizes the result.",
4
- "tool": true,
5
4
  "async": true,
6
5
  "imports": {
7
6
  "coordinator": "subagent-review-coordinator.json"
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "pipeline-room-swarm",
3
+ "async": true,
4
+ "args": [
5
+ "mission:string",
6
+ "model:string",
7
+ "thinking:string",
8
+ "roles:string",
9
+ "roles_path:path",
10
+ "rounds:int",
11
+ "delay:int",
12
+ "locker:bool",
13
+ "locker_lease_ms:int",
14
+ "artifact_path:path",
15
+ "repo:path"
16
+ ],
17
+ "defaults": {
18
+ "thinking": "off",
19
+ "roles": "",
20
+ "roles_path": "",
21
+ "rounds": "4",
22
+ "delay": "10",
23
+ "locker": "false",
24
+ "locker_lease_ms": "600000",
25
+ "artifact_path": "{state_dir}/room-swarm-artifact.md",
26
+ "repo": "~/.pi/agent/extensions/pi-actors"
27
+ },
28
+ "artifacts": {
29
+ "artifact": "{artifact_path}",
30
+ "locker_journal": "{state_dir}/locker/journal.jsonl",
31
+ "locker_locks": "{state_dir}/locker/locks.json",
32
+ "locker_queue": "{state_dir}/locker/queue.json"
33
+ },
34
+ "mailbox": {
35
+ "accepts": [
36
+ "control.stop",
37
+ "control.cancel",
38
+ "control.kill"
39
+ ],
40
+ "emits": [
41
+ "chat.message",
42
+ "actor.join",
43
+ "actor.leave",
44
+ "run.done",
45
+ "run.failed"
46
+ ]
47
+ },
48
+ "template": "{repo}/scripts/room-swarm.mjs --run-id={run_id} --mission={mission} --model={model} --thinking={thinking} --roles={roles} --roles-path={roles_path} --rounds={rounds} --delay={delay} --locker={locker} --locker-lease-ms={locker_lease_ms} --artifact-path={artifact_path}"
49
+ }