@kevin5251984/guild 0.2.17 → 0.2.19
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/package.json +1 -1
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/compact.ts +27 -0
- package/src/db.ts +199 -64
- package/src/generate.ts +58 -27
- package/src/handlers.ts +341 -54
- package/src/harness.ts +21 -8
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +400 -58
- package/src/memory.ts +69 -0
- package/src/mention.ts +119 -12
- package/src/oauth.ts +202 -25
- package/src/opencode-free.ts +247 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +212 -43
- package/src/public/i18n.js +41 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +491 -0
- package/src/public/mobile.html +784 -0
- package/src/public/settings.html +188 -27
- package/src/public/skills-add.html +8 -0
- package/src/public/studio.html +171 -117
- package/src/public/style.css +220 -26
- package/src/public/subagents-add.html +8 -0
- package/src/router.ts +76 -5
- package/src/store.ts +161 -5
- package/src/subagent.ts +233 -6
- package/src/tools.ts +124 -18
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/vendor/protocol/src/index.ts +14 -1
package/src/store.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { DEFAULT_BOTS } from "./catalog/default-bots.ts";
|
|
|
26
26
|
import { CATALOG_SKILLS } from "./catalog/skills.ts";
|
|
27
27
|
import { CATALOG_SUBAGENTS } from "./catalog/subagents.ts";
|
|
28
28
|
import { parseAgentFile } from "./agent-file.ts";
|
|
29
|
+
import { parseMentionIds, sanitizeMentionIds } from "./mention.ts";
|
|
29
30
|
|
|
30
31
|
const MARKDOWN: Record<LibraryKind, string> = {
|
|
31
32
|
souls: "SOUL.md",
|
|
@@ -39,6 +40,10 @@ const GENERAL_CHANNEL_ID = "channel-general";
|
|
|
39
40
|
const NAV_PREVIEW_CAP = 120;
|
|
40
41
|
/** Project channels (not #general). Reuse seats first; human adds specialists. */
|
|
41
42
|
export const CHANNEL_ROSTER_CAP = 6;
|
|
43
|
+
/** Parent → child → grandchild. Deeper than this hides in the sidebar. */
|
|
44
|
+
export const BRANCH_DEPTH_CAP = 3;
|
|
45
|
+
/** Messages copied from the parent, ending at the branched row. */
|
|
46
|
+
export const BRANCH_CONTEXT_CAP = 20;
|
|
42
47
|
|
|
43
48
|
export function isGeneralChannel(room: { id: string; name: string }): boolean {
|
|
44
49
|
return room.id === GENERAL_CHANNEL_ID || room.name === "general";
|
|
@@ -384,6 +389,7 @@ export class GuildStore {
|
|
|
384
389
|
steers?.delete(botId);
|
|
385
390
|
if (live && live.size === 0) this.liveTurns.delete(roomId);
|
|
386
391
|
if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
|
|
392
|
+
this.spillTrajectoryIfIdle(roomId);
|
|
387
393
|
return hadLive;
|
|
388
394
|
}
|
|
389
395
|
const group = this.turnGroups.get(controller.signal);
|
|
@@ -400,6 +406,7 @@ export class GuildStore {
|
|
|
400
406
|
if (live && live.size === 0) this.liveTurns.delete(roomId);
|
|
401
407
|
if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
|
|
402
408
|
if (!controller.signal.aborted) controller.abort();
|
|
409
|
+
this.spillTrajectoryIfIdle(roomId);
|
|
403
410
|
return true;
|
|
404
411
|
}
|
|
405
412
|
const room = this.botAborts.get(roomId);
|
|
@@ -417,6 +424,7 @@ export class GuildStore {
|
|
|
417
424
|
aborted = true;
|
|
418
425
|
}
|
|
419
426
|
}
|
|
427
|
+
this.spillTrajectoryIfIdle(roomId);
|
|
420
428
|
return aborted || Boolean(room);
|
|
421
429
|
}
|
|
422
430
|
|
|
@@ -435,10 +443,19 @@ export class GuildStore {
|
|
|
435
443
|
if (room && room.size === 0) this.botAborts.delete(roomId);
|
|
436
444
|
if (live && live.size === 0) this.liveTurns.delete(roomId);
|
|
437
445
|
if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
|
|
438
|
-
|
|
446
|
+
if (!group.controller.signal.aborted) group.controller.abort();
|
|
447
|
+
} else {
|
|
448
|
+
this.botAborts.delete(roomId);
|
|
449
|
+
this.clearLiveTurn(roomId);
|
|
439
450
|
}
|
|
440
|
-
this.
|
|
441
|
-
|
|
451
|
+
this.spillTrajectoryIfIdle(roomId);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Warehouse overflow once, after every bot in the room has stopped. */
|
|
455
|
+
private spillTrajectoryIfIdle(roomId: string): void {
|
|
456
|
+
if (this.botAborts.get(roomId)?.size) return;
|
|
457
|
+
if (this.liveTurns.get(roomId)?.size) return;
|
|
458
|
+
this.db.spillColdTrajectory(roomId);
|
|
442
459
|
}
|
|
443
460
|
|
|
444
461
|
pushSteer(roomId: string, text: string, botId?: string): void {
|
|
@@ -580,6 +597,9 @@ export class GuildStore {
|
|
|
580
597
|
if (room.id === GENERAL_CHANNEL_ID || room.name === "general") {
|
|
581
598
|
throw new StoreError(400, "cannot delete #general");
|
|
582
599
|
}
|
|
600
|
+
for (const child of this.listChannels().filter((item) => item.parentId === id)) {
|
|
601
|
+
this.deleteChannel(child.id);
|
|
602
|
+
}
|
|
583
603
|
this.removeRoomDir(room.id);
|
|
584
604
|
return { ok: true, id: room.id };
|
|
585
605
|
}
|
|
@@ -605,6 +625,7 @@ export class GuildStore {
|
|
|
605
625
|
name?: string;
|
|
606
626
|
handle?: string;
|
|
607
627
|
oneLiner?: string;
|
|
628
|
+
portrait?: string | null;
|
|
608
629
|
skillIds?: string[];
|
|
609
630
|
soul?: { name: string; body: string };
|
|
610
631
|
agent?: { name: string; body: string };
|
|
@@ -646,6 +667,9 @@ export class GuildStore {
|
|
|
646
667
|
handle,
|
|
647
668
|
skillIds,
|
|
648
669
|
oneLiner: input.oneLiner?.trim() || bot.oneLiner,
|
|
670
|
+
portrait: Object.hasOwn(input, "portrait")
|
|
671
|
+
? normalizePortrait(input.portrait)
|
|
672
|
+
: bot.portrait,
|
|
649
673
|
model: Object.hasOwn(input, "model") ? input.model ?? null : bot.model,
|
|
650
674
|
};
|
|
651
675
|
this.writeBot(next);
|
|
@@ -789,6 +813,48 @@ export class GuildStore {
|
|
|
789
813
|
return room;
|
|
790
814
|
}
|
|
791
815
|
|
|
816
|
+
createBranch(parentId: string, messageId: string, name?: string): Room {
|
|
817
|
+
const parent = this.getRoom(parentId);
|
|
818
|
+
if (!parent) throw new StoreError(404, "channel not found");
|
|
819
|
+
if (parent.kind !== "channel") {
|
|
820
|
+
throw new StoreError(400, "can only branch a channel");
|
|
821
|
+
}
|
|
822
|
+
const source = this.listMessages(parentId).find((item) => item.id === messageId);
|
|
823
|
+
if (!source) throw new StoreError(404, "message not found");
|
|
824
|
+
if (branchDepth(this, parent) >= BRANCH_DEPTH_CAP) {
|
|
825
|
+
throw new StoreError(400, "too many nested branches");
|
|
826
|
+
}
|
|
827
|
+
const trimmed = String(name || "").replace(/^#/, "").trim() || clipBranchName(source.body);
|
|
828
|
+
if (trimmed === "general") {
|
|
829
|
+
throw new StoreError(400, "cannot create #general");
|
|
830
|
+
}
|
|
831
|
+
const taken = this.listChannels().map((room) => room.name);
|
|
832
|
+
const uniqueName = uniqueChannelName(trimmed, taken);
|
|
833
|
+
const existingIds = this.listChannels().map((room) => room.id);
|
|
834
|
+
const slug = slugify(uniqueName);
|
|
835
|
+
const baseId =
|
|
836
|
+
slug && slug !== "item" && slug !== "general"
|
|
837
|
+
? `channel-${slug}`
|
|
838
|
+
: `channel-${randomUUID().slice(0, 8)}`;
|
|
839
|
+
const room: Room = {
|
|
840
|
+
id: uniqueSlug(baseId, existingIds),
|
|
841
|
+
kind: "channel",
|
|
842
|
+
name: uniqueName,
|
|
843
|
+
memberIds: [...parent.memberIds],
|
|
844
|
+
createdAt: new Date().toISOString(),
|
|
845
|
+
parentId: parent.id,
|
|
846
|
+
branchFromId: source.id,
|
|
847
|
+
};
|
|
848
|
+
this.writeRoom(room);
|
|
849
|
+
this.writeChannelMd(room.id, this.readChannelMd(parent.id));
|
|
850
|
+
const history = this.listMessages(parent.id);
|
|
851
|
+
const at = history.findIndex((item) => item.id === source.id);
|
|
852
|
+
const from = Math.max(0, at + 1 - BRANCH_CONTEXT_CAP);
|
|
853
|
+
const window = at < 0 ? [source] : history.slice(from, at + 1);
|
|
854
|
+
this.writeMessages(room.id, cloneBranchMessages(window, room.id));
|
|
855
|
+
return room;
|
|
856
|
+
}
|
|
857
|
+
|
|
792
858
|
renameChannel(id: string, name: string): Room {
|
|
793
859
|
const trimmed = String(name || "").trim();
|
|
794
860
|
if (!trimmed) throw new StoreError(400, "channel name is required");
|
|
@@ -914,6 +980,7 @@ export class GuildStore {
|
|
|
914
980
|
usage?: ChatUsage,
|
|
915
981
|
steer?: boolean,
|
|
916
982
|
steerBotId?: string,
|
|
983
|
+
mentions?: string[],
|
|
917
984
|
): ChatMessage {
|
|
918
985
|
const room = this.getRoom(roomId);
|
|
919
986
|
if (!room) throw new StoreError(404, "room not found");
|
|
@@ -921,6 +988,12 @@ export class GuildStore {
|
|
|
921
988
|
if (!text) throw new StoreError(400, "message is required");
|
|
922
989
|
const now = new Date().toISOString();
|
|
923
990
|
const startedAt = author !== "you" ? usage?.startedAt : undefined;
|
|
991
|
+
const bots = this.listBots();
|
|
992
|
+
const mentionIds = (
|
|
993
|
+
mentions !== undefined
|
|
994
|
+
? sanitizeMentionIds(mentions, bots)
|
|
995
|
+
: parseMentionIds(text, bots, author === "you" ? "user" : "bot")
|
|
996
|
+
).filter((id) => id !== author);
|
|
924
997
|
const message: ChatMessage = {
|
|
925
998
|
id: randomUUID(),
|
|
926
999
|
roomId,
|
|
@@ -934,15 +1007,27 @@ export class GuildStore {
|
|
|
934
1007
|
...(author !== "you" ? { finishedAt: now } : {}),
|
|
935
1008
|
...(steer ? { steer: true } : {}),
|
|
936
1009
|
...(steer && steerBotId ? { steerBotId } : {}),
|
|
1010
|
+
mentions: mentionIds,
|
|
937
1011
|
};
|
|
938
1012
|
this.db.appendMessage(message);
|
|
939
1013
|
return message;
|
|
940
1014
|
}
|
|
941
1015
|
|
|
942
|
-
updateMessage(
|
|
1016
|
+
updateMessage(
|
|
1017
|
+
roomId: string,
|
|
1018
|
+
messageId: string,
|
|
1019
|
+
body: string,
|
|
1020
|
+
mentions?: string[],
|
|
1021
|
+
): ChatMessage {
|
|
943
1022
|
const text = body.trim();
|
|
944
1023
|
if (!text) throw new StoreError(400, "message is required");
|
|
945
|
-
const
|
|
1024
|
+
const bots = this.listBots();
|
|
1025
|
+
const mentionIds = (
|
|
1026
|
+
mentions !== undefined
|
|
1027
|
+
? sanitizeMentionIds(mentions, bots)
|
|
1028
|
+
: parseMentionIds(text, bots, "user")
|
|
1029
|
+
);
|
|
1030
|
+
const next = this.db.updateMessageBody(roomId, messageId, text, mentionIds);
|
|
946
1031
|
if (!next) throw new StoreError(404, "message not found");
|
|
947
1032
|
return next;
|
|
948
1033
|
}
|
|
@@ -957,12 +1042,18 @@ export class GuildStore {
|
|
|
957
1042
|
const text = body.trim();
|
|
958
1043
|
if (!text) throw new StoreError(400, "message is required");
|
|
959
1044
|
const now = new Date().toISOString();
|
|
1045
|
+
const current = this.listMessages(roomId).find((item) => item.id === messageId);
|
|
1046
|
+
const bots = this.listBots();
|
|
1047
|
+
const mentionIds = parseMentionIds(text, bots, "bot").filter(
|
|
1048
|
+
(id) => id !== current?.author,
|
|
1049
|
+
);
|
|
960
1050
|
const next = this.db.replaceMessage(roomId, messageId, {
|
|
961
1051
|
body: text,
|
|
962
1052
|
parts: parts && parts.length ? parts : undefined,
|
|
963
1053
|
usage,
|
|
964
1054
|
createdAt: usage?.startedAt || now,
|
|
965
1055
|
finishedAt: now,
|
|
1056
|
+
mentions: mentionIds,
|
|
966
1057
|
});
|
|
967
1058
|
if (!next) throw new StoreError(404, "message not found");
|
|
968
1059
|
return next;
|
|
@@ -1169,6 +1260,16 @@ export class GuildStore {
|
|
|
1169
1260
|
}
|
|
1170
1261
|
}
|
|
1171
1262
|
|
|
1263
|
+
function normalizePortrait(raw: string | null | undefined): string | undefined {
|
|
1264
|
+
if (raw == null) return undefined;
|
|
1265
|
+
const value = raw.trim();
|
|
1266
|
+
if (!value) return undefined;
|
|
1267
|
+
if (!/^\/generated\/[A-Za-z0-9._-]+$/.test(value)) {
|
|
1268
|
+
throw new StoreError(400, "invalid portrait");
|
|
1269
|
+
}
|
|
1270
|
+
return value;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1172
1273
|
export class StoreError extends Error {
|
|
1173
1274
|
constructor(
|
|
1174
1275
|
readonly status: number,
|
|
@@ -1192,3 +1293,58 @@ function uniqueSlug(base: string, existing: string[]): string {
|
|
|
1192
1293
|
if (!existing.includes(base)) return base;
|
|
1193
1294
|
return `${base}-${randomUUID().slice(0, 8)}`;
|
|
1194
1295
|
}
|
|
1296
|
+
|
|
1297
|
+
function cloneBranchMessages(items: ChatMessage[], roomId: string): ChatMessage[] {
|
|
1298
|
+
const idMap = new Map<string, string>();
|
|
1299
|
+
return items.map((item) => {
|
|
1300
|
+
const id = randomUUID();
|
|
1301
|
+
idMap.set(item.id, id);
|
|
1302
|
+
const next: ChatMessage = {
|
|
1303
|
+
id,
|
|
1304
|
+
roomId,
|
|
1305
|
+
author: item.author,
|
|
1306
|
+
body: item.body,
|
|
1307
|
+
createdAt: item.createdAt,
|
|
1308
|
+
};
|
|
1309
|
+
if (item.parts?.length) next.parts = item.parts;
|
|
1310
|
+
if (item.attachments?.length) next.attachments = item.attachments;
|
|
1311
|
+
if (item.usage) next.usage = item.usage;
|
|
1312
|
+
if (item.finishedAt) next.finishedAt = item.finishedAt;
|
|
1313
|
+
if (item.mentions) next.mentions = item.mentions;
|
|
1314
|
+
if (item.steer) next.steer = true;
|
|
1315
|
+
if (item.steerBotId) next.steerBotId = item.steerBotId;
|
|
1316
|
+
const replyTo = item.replyTo ? idMap.get(item.replyTo) : undefined;
|
|
1317
|
+
if (replyTo) next.replyTo = replyTo;
|
|
1318
|
+
return next;
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function clipBranchName(body: string): string {
|
|
1323
|
+
return String(body || "")
|
|
1324
|
+
.replace(/\s+/g, " ")
|
|
1325
|
+
.trim()
|
|
1326
|
+
.slice(0, 28) || "branch";
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function uniqueChannelName(name: string, taken: string[]): string {
|
|
1330
|
+
if (!taken.includes(name)) return name;
|
|
1331
|
+
for (let i = 2; i < 50; i++) {
|
|
1332
|
+
const next = `${name} ${i}`;
|
|
1333
|
+
if (!taken.includes(next)) return next;
|
|
1334
|
+
}
|
|
1335
|
+
return `${name} ${randomUUID().slice(0, 4)}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function branchDepth(store: GuildStore, room: Room): number {
|
|
1339
|
+
let depth = 0;
|
|
1340
|
+
let current: Room | null = room;
|
|
1341
|
+
const seen = new Set<string>();
|
|
1342
|
+
while (current?.parentId) {
|
|
1343
|
+
if (seen.has(current.id)) break;
|
|
1344
|
+
seen.add(current.id);
|
|
1345
|
+
depth += 1;
|
|
1346
|
+
current = store.getRoom(current.parentId);
|
|
1347
|
+
if (depth > 16) break;
|
|
1348
|
+
}
|
|
1349
|
+
return depth;
|
|
1350
|
+
}
|
package/src/subagent.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { parseAgentFile } from "./agent-file.ts";
|
|
2
3
|
import { listHostAgents, type HostAgent } from "./host-agents.ts";
|
|
3
4
|
import type { LibraryItem } from "@guild/protocol";
|
|
5
|
+
import { parseSandbox, type Sandbox } from "./harness.ts";
|
|
4
6
|
import {
|
|
5
7
|
hostContext,
|
|
8
|
+
type SpawnHandle,
|
|
6
9
|
type SubAgentRef,
|
|
7
10
|
type ToolContext,
|
|
8
11
|
type ToolOutcome,
|
|
@@ -70,6 +73,18 @@ function agentKey(value: string): string {
|
|
|
70
73
|
return value.trim().replace(/^\/+/, "").toLowerCase();
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/** Parent read_only cannot escalate via spawn. Explorer from full_access keeps run. */
|
|
77
|
+
export function childSpawnPolicy(
|
|
78
|
+
parentSandbox: ToolContext["sandbox"],
|
|
79
|
+
agentReadOnly: boolean,
|
|
80
|
+
): { sandbox: Sandbox; allowWrite: boolean } {
|
|
81
|
+
const parent = parseSandbox(parentSandbox);
|
|
82
|
+
if (parent === "read_only") {
|
|
83
|
+
return { sandbox: "read_only", allowWrite: false };
|
|
84
|
+
}
|
|
85
|
+
return { sandbox: parent, allowWrite: !agentReadOnly };
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
export function resolveSubagent(
|
|
74
89
|
name: string,
|
|
75
90
|
agents: SubAgentRef[],
|
|
@@ -89,11 +104,221 @@ export function resolveSubagent(
|
|
|
89
104
|
|
|
90
105
|
const CHILD_TOOLS = `You ARE already running on the user's local computer (Guild).
|
|
91
106
|
Tools: run, read, write, list, skill, image_gen. You cannot spawn subagents.
|
|
92
|
-
Never say you cannot access this machine. Check [exit code: N] on every run
|
|
107
|
+
Never say you cannot access this machine. Check [exit code: N] on every run.
|
|
108
|
+
Independent searches: emit multiple tool calls in one round; they run in parallel.`;
|
|
93
109
|
|
|
94
110
|
const CHILD_TOOLS_RO = `You ARE already running on the user's local computer (Guild).
|
|
95
111
|
Tools: run, read, list, skill. You cannot write files and cannot spawn subagents.
|
|
96
|
-
Read-only. Never edit, patch, or create files. Check [exit code: N] on every run
|
|
112
|
+
Read-only. Never edit, patch, or create files. Check [exit code: N] on every run.
|
|
113
|
+
Independent searches: emit multiple tool calls in one round; they run in parallel.`;
|
|
114
|
+
|
|
115
|
+
export const SPAWN_MAX_PARALLEL = 8;
|
|
116
|
+
export const SPAWN_CONCURRENCY = 4;
|
|
117
|
+
|
|
118
|
+
export type SpawnJob = {
|
|
119
|
+
prompt: string;
|
|
120
|
+
name: string;
|
|
121
|
+
description: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Devin luna-explore / Pi scout → Guild explorer. */
|
|
125
|
+
export function spawnProfile(raw: string): string {
|
|
126
|
+
const key = raw.trim().toLowerCase();
|
|
127
|
+
if (!key) return "";
|
|
128
|
+
if (key === "luna-explore" || key === "explore" || key === "scout") {
|
|
129
|
+
return "explorer";
|
|
130
|
+
}
|
|
131
|
+
if (key === "luna-general" || key === "general") return "worker";
|
|
132
|
+
if (key === "luna-reviewer") return "reviewer";
|
|
133
|
+
return raw.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function recordOf(value: unknown): Record<string, unknown> | null {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
138
|
+
return value as Record<string, unknown>;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function flagTrue(value: unknown): boolean {
|
|
142
|
+
return value === true || value === "true";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function flagFalse(value: unknown): boolean {
|
|
146
|
+
return value === false || value === "false";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function oneJob(raw: Record<string, unknown>): SpawnJob {
|
|
150
|
+
const prompt = String(raw.prompt || raw.task || "").trim();
|
|
151
|
+
const name = spawnProfile(
|
|
152
|
+
String(raw.profile || raw.name || raw.agent || raw.subagent_type || ""),
|
|
153
|
+
);
|
|
154
|
+
const description = String(raw.title || raw.description || "").trim();
|
|
155
|
+
return { prompt, name, description };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handlesOf(ctx: ToolContext) {
|
|
159
|
+
if (!ctx.spawnHandles) ctx.spawnHandles = new Map();
|
|
160
|
+
return ctx.spawnHandles;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isAbortError(error: unknown): boolean {
|
|
164
|
+
return Boolean(
|
|
165
|
+
error &&
|
|
166
|
+
typeof error === "object" &&
|
|
167
|
+
"name" in error &&
|
|
168
|
+
(error as { name: string }).name === "AbortError",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function startBackground(job: SpawnJob, ctx: ToolContext) {
|
|
173
|
+
const id = randomUUID();
|
|
174
|
+
const title = job.description || job.name || "worker";
|
|
175
|
+
const profile = job.name || "worker";
|
|
176
|
+
const abort = new AbortController();
|
|
177
|
+
const parent = ctx.signal;
|
|
178
|
+
if (parent) {
|
|
179
|
+
if (parent.aborted) abort.abort();
|
|
180
|
+
else parent.addEventListener("abort", () => abort.abort(), { once: true });
|
|
181
|
+
}
|
|
182
|
+
const childCtx: ToolContext = {
|
|
183
|
+
...ctx,
|
|
184
|
+
signal: abort.signal,
|
|
185
|
+
spawnHandles: undefined,
|
|
186
|
+
};
|
|
187
|
+
const handle: SpawnHandle = {
|
|
188
|
+
id,
|
|
189
|
+
title,
|
|
190
|
+
profile,
|
|
191
|
+
abort,
|
|
192
|
+
done: Promise.resolve({ text: "", isError: false }),
|
|
193
|
+
};
|
|
194
|
+
handle.done = spawnSubagent({ ...job, ctx: childCtx })
|
|
195
|
+
.then((outcome) => {
|
|
196
|
+
handle.outcome = outcome;
|
|
197
|
+
return outcome;
|
|
198
|
+
})
|
|
199
|
+
.catch((error: unknown) => {
|
|
200
|
+
const outcome: ToolOutcome = {
|
|
201
|
+
text:
|
|
202
|
+
isAbortError(error) || abort.signal.aborted
|
|
203
|
+
? "aborted"
|
|
204
|
+
: error instanceof Error
|
|
205
|
+
? error.message
|
|
206
|
+
: String(error),
|
|
207
|
+
isError: true,
|
|
208
|
+
};
|
|
209
|
+
handle.outcome = outcome;
|
|
210
|
+
return outcome;
|
|
211
|
+
});
|
|
212
|
+
handlesOf(ctx).set(id, handle);
|
|
213
|
+
return handle;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function ackBackground(
|
|
217
|
+
rows: { id: string; title: string; profile: string }[],
|
|
218
|
+
): string {
|
|
219
|
+
return rows
|
|
220
|
+
.map(
|
|
221
|
+
(row) =>
|
|
222
|
+
`agent_id: ${row.id}\ntitle: ${row.title}\nprofile: ${row.profile}\nstatus: running\nCall read_spawn with this agent_id (block=true) before the final reply.`,
|
|
223
|
+
)
|
|
224
|
+
.join("\n\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Pi subagent: single {prompt|task, name|agent} or parallel tasks[]. */
|
|
228
|
+
export function spawnJobs(args: Record<string, unknown>): SpawnJob[] {
|
|
229
|
+
if (Array.isArray(args.tasks) && args.tasks.length) {
|
|
230
|
+
return args.tasks.map((item) => oneJob(recordOf(item) || {}));
|
|
231
|
+
}
|
|
232
|
+
return [oneJob(args)];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function mapWithConcurrency<T, R>(
|
|
236
|
+
items: T[],
|
|
237
|
+
concurrency: number,
|
|
238
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
239
|
+
): Promise<R[]> {
|
|
240
|
+
if (!items.length) return [];
|
|
241
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
242
|
+
const out: R[] = new Array(items.length);
|
|
243
|
+
let next = 0;
|
|
244
|
+
await Promise.all(
|
|
245
|
+
Array.from({ length: limit }, async () => {
|
|
246
|
+
while (true) {
|
|
247
|
+
const i = next++;
|
|
248
|
+
if (i >= items.length) return;
|
|
249
|
+
out[i] = await fn(items[i], i);
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function runSpawnJobs(
|
|
257
|
+
args: Record<string, unknown>,
|
|
258
|
+
ctx: ToolContext,
|
|
259
|
+
): Promise<ToolOutcome> {
|
|
260
|
+
const jobs = spawnJobs(args);
|
|
261
|
+
if (!jobs.length || jobs.some((job) => !job.prompt)) {
|
|
262
|
+
return { text: "spawn needs a prompt or task", isError: true };
|
|
263
|
+
}
|
|
264
|
+
if (jobs.length > SPAWN_MAX_PARALLEL) {
|
|
265
|
+
return {
|
|
266
|
+
text: `Too many parallel tasks (${jobs.length}). Max is ${SPAWN_MAX_PARALLEL}.`,
|
|
267
|
+
isError: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const background = flagTrue(args.background) || flagTrue(args.is_background);
|
|
271
|
+
if (background) {
|
|
272
|
+
const started = jobs.map((job) => startBackground(job, ctx));
|
|
273
|
+
return { text: ackBackground(started), isError: false };
|
|
274
|
+
}
|
|
275
|
+
if (jobs.length === 1) {
|
|
276
|
+
return spawnSubagent({ ...jobs[0], ctx });
|
|
277
|
+
}
|
|
278
|
+
const results = await mapWithConcurrency(jobs, SPAWN_CONCURRENCY, (job) =>
|
|
279
|
+
spawnSubagent({ ...job, ctx }),
|
|
280
|
+
);
|
|
281
|
+
const failed = results.filter((row) => row.isError).length;
|
|
282
|
+
const body = results
|
|
283
|
+
.map((row, i) => {
|
|
284
|
+
const label = jobs[i].description || jobs[i].name || "worker";
|
|
285
|
+
const status = row.isError ? "failed" : "completed";
|
|
286
|
+
return `### [${label}] ${status}\n\n${row.text}`;
|
|
287
|
+
})
|
|
288
|
+
.join("\n\n---\n\n");
|
|
289
|
+
return {
|
|
290
|
+
text: `Parallel: ${results.length - failed}/${results.length} succeeded\n\n${body}`,
|
|
291
|
+
isError: failed === results.length,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function readSpawn(
|
|
296
|
+
args: Record<string, unknown>,
|
|
297
|
+
ctx: ToolContext,
|
|
298
|
+
): Promise<ToolOutcome> {
|
|
299
|
+
const id = String(args.agent_id || args.id || "").trim();
|
|
300
|
+
if (!id) return { text: "read_spawn needs agent_id", isError: true };
|
|
301
|
+
const handle = handlesOf(ctx).get(id);
|
|
302
|
+
if (!handle) {
|
|
303
|
+
return {
|
|
304
|
+
text: `unknown agent_id ${id}. It must come from a background spawn in this turn.`,
|
|
305
|
+
isError: true,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (flagFalse(args.block) && !handle.outcome) {
|
|
309
|
+
return {
|
|
310
|
+
text: `agent_id: ${handle.id}\ntitle: ${handle.title}\nprofile: ${handle.profile}\nstatus: running`,
|
|
311
|
+
isError: false,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const outcome = await handle.done;
|
|
315
|
+
return {
|
|
316
|
+
text: `# ${handle.title}\nagent_id: ${handle.id}\nprofile: ${handle.profile}\nstatus: ${
|
|
317
|
+
outcome.isError ? "failed" : "completed"
|
|
318
|
+
}\n\n${outcome.text}`,
|
|
319
|
+
isError: outcome.isError,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
97
322
|
|
|
98
323
|
export async function spawnSubagent(input: {
|
|
99
324
|
prompt: string;
|
|
@@ -115,12 +340,13 @@ export async function spawnSubagent(input: {
|
|
|
115
340
|
? input.ctx.subagents
|
|
116
341
|
: listSpawnRefs([]);
|
|
117
342
|
const agent = resolveSubagent(input.name || "worker", agents);
|
|
343
|
+
const child = childSpawnPolicy(input.ctx.sandbox, agent.readOnly);
|
|
118
344
|
const { llmComplete } = await import("./llm.ts");
|
|
119
345
|
const label = (input.description || agent.name).trim();
|
|
120
346
|
const system = [
|
|
121
347
|
agent.instructions,
|
|
122
348
|
hostContext(),
|
|
123
|
-
|
|
349
|
+
child.allowWrite ? CHILD_TOOLS : CHILD_TOOLS_RO,
|
|
124
350
|
]
|
|
125
351
|
.filter(Boolean)
|
|
126
352
|
.join("\n\n");
|
|
@@ -130,7 +356,7 @@ export async function spawnSubagent(input: {
|
|
|
130
356
|
system,
|
|
131
357
|
messages: [{ role: "user", content: prompt }],
|
|
132
358
|
temperature: 0.3,
|
|
133
|
-
role: "
|
|
359
|
+
role: "spawn",
|
|
134
360
|
tools: true,
|
|
135
361
|
skills: input.ctx.skills,
|
|
136
362
|
toolCtx: {
|
|
@@ -139,10 +365,11 @@ export async function spawnSubagent(input: {
|
|
|
139
365
|
dataDir,
|
|
140
366
|
env: input.ctx.env,
|
|
141
367
|
spawnDepth: 1,
|
|
142
|
-
allowWrite:
|
|
143
|
-
sandbox:
|
|
368
|
+
allowWrite: child.allowWrite,
|
|
369
|
+
sandbox: child.sandbox,
|
|
144
370
|
workspace: input.ctx.workspace,
|
|
145
371
|
dispatch: input.ctx.dispatch,
|
|
372
|
+
signal: input.ctx.signal,
|
|
146
373
|
},
|
|
147
374
|
});
|
|
148
375
|
if (!result) {
|