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