@nebutra/agent-runtime 0.2.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.
- package/.turbo/turbo-build.log +115 -0
- package/.turbo/turbo-test.log +44 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +253 -0
- package/LICENSE +676 -0
- package/README.md +50 -0
- package/dist/adapters/dispatcher-sse.d.ts +68 -0
- package/dist/adapters/dispatcher-sse.js +11 -0
- package/dist/adapters/dispatcher-sse.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mcp-catalog.d.ts +58 -0
- package/dist/adapters/mcp-catalog.js +9 -0
- package/dist/adapters/mcp-catalog.js.map +1 -0
- package/dist/adapters/prisma-rollout.d.ts +60 -0
- package/dist/adapters/prisma-rollout.js +7 -0
- package/dist/adapters/prisma-rollout.js.map +1 -0
- package/dist/chunk-24ZXP7FI.js +93 -0
- package/dist/chunk-24ZXP7FI.js.map +1 -0
- package/dist/chunk-2DA6Q6TN.js +126 -0
- package/dist/chunk-2DA6Q6TN.js.map +1 -0
- package/dist/chunk-37BBB2P2.js +73 -0
- package/dist/chunk-37BBB2P2.js.map +1 -0
- package/dist/chunk-57W3AR43.js +52 -0
- package/dist/chunk-57W3AR43.js.map +1 -0
- package/dist/chunk-5N4644PB.js +67 -0
- package/dist/chunk-5N4644PB.js.map +1 -0
- package/dist/chunk-5YS7WAPS.js +177 -0
- package/dist/chunk-5YS7WAPS.js.map +1 -0
- package/dist/chunk-6EGG2OZC.js +13 -0
- package/dist/chunk-6EGG2OZC.js.map +1 -0
- package/dist/chunk-7BUOF367.js +126 -0
- package/dist/chunk-7BUOF367.js.map +1 -0
- package/dist/chunk-BJBBR3QA.js +121 -0
- package/dist/chunk-BJBBR3QA.js.map +1 -0
- package/dist/chunk-CGRCUKGT.js +73 -0
- package/dist/chunk-CGRCUKGT.js.map +1 -0
- package/dist/chunk-FUG5DT2C.js +75 -0
- package/dist/chunk-FUG5DT2C.js.map +1 -0
- package/dist/chunk-LO24VOA3.js +199 -0
- package/dist/chunk-LO24VOA3.js.map +1 -0
- package/dist/chunk-MUF7ZZTO.js +57 -0
- package/dist/chunk-MUF7ZZTO.js.map +1 -0
- package/dist/chunk-NN7DATXA.js +46 -0
- package/dist/chunk-NN7DATXA.js.map +1 -0
- package/dist/chunk-PGGWSUTM.js +33 -0
- package/dist/chunk-PGGWSUTM.js.map +1 -0
- package/dist/chunk-RDKYDMXT.js +135 -0
- package/dist/chunk-RDKYDMXT.js.map +1 -0
- package/dist/chunk-YYFPDBJG.js +63 -0
- package/dist/chunk-YYFPDBJG.js.map +1 -0
- package/dist/chunk-ZMYX5VBU.js +135 -0
- package/dist/chunk-ZMYX5VBU.js.map +1 -0
- package/dist/chunk-ZTSKS42I.js +131 -0
- package/dist/chunk-ZTSKS42I.js.map +1 -0
- package/dist/commands.d.ts +74 -0
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -0
- package/dist/definitions.d.ts +94 -0
- package/dist/definitions.js +15 -0
- package/dist/definitions.js.map +1 -0
- package/dist/dispatcher.d.ts +50 -0
- package/dist/dispatcher.js +8 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/durable-turn.d.ts +58 -0
- package/dist/durable-turn.js +9 -0
- package/dist/durable-turn.js.map +1 -0
- package/dist/hook-pipeline.d.ts +114 -0
- package/dist/hook-pipeline.js +13 -0
- package/dist/hook-pipeline.js.map +1 -0
- package/dist/index.d.ts +1874 -0
- package/dist/index.js +3117 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +78 -0
- package/dist/loop.js +9 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-bridge.d.ts +48 -0
- package/dist/mcp-bridge.js +8 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/model.d.ts +154 -0
- package/dist/model.js +9 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +130 -0
- package/dist/policy.js +23 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +170 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rollout-store-persistent.d.ts +48 -0
- package/dist/rollout-store-persistent.js +9 -0
- package/dist/rollout-store-persistent.js.map +1 -0
- package/dist/rollout.d.ts +82 -0
- package/dist/rollout.js +15 -0
- package/dist/rollout.js.map +1 -0
- package/dist/sandbox.d.ts +65 -0
- package/dist/sandbox.js +15 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/skills.d.ts +93 -0
- package/dist/skills.js +10 -0
- package/dist/skills.js.map +1 -0
- package/dist/subagents.d.ts +129 -0
- package/dist/subagents.js +21 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +77 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +74 -0
- package/src/adapters/dispatcher-sse.test.ts +218 -0
- package/src/adapters/dispatcher-sse.ts +222 -0
- package/src/adapters/index.ts +18 -0
- package/src/adapters/mcp-catalog.test.ts +213 -0
- package/src/adapters/mcp-catalog.ts +188 -0
- package/src/adapters/prisma-rollout.test.ts +153 -0
- package/src/adapters/prisma-rollout.ts +104 -0
- package/src/agent-runtime.test.ts +176 -0
- package/src/artifact-stream.test.ts +330 -0
- package/src/artifact-stream.ts +453 -0
- package/src/channel-gateway.test.ts +432 -0
- package/src/channel-gateway.ts +357 -0
- package/src/code-review.test.ts +501 -0
- package/src/code-review.ts +495 -0
- package/src/command-suggestions.test.ts +251 -0
- package/src/command-suggestions.ts +338 -0
- package/src/commands.test.ts +184 -0
- package/src/commands.ts +140 -0
- package/src/commit-message.test.ts +249 -0
- package/src/commit-message.ts +180 -0
- package/src/context-compaction.test.ts +522 -0
- package/src/context-compaction.ts +434 -0
- package/src/definitions.test.ts +78 -0
- package/src/definitions.ts +190 -0
- package/src/deployment-status.test.ts +215 -0
- package/src/deployment-status.ts +227 -0
- package/src/design-context.test.ts +195 -0
- package/src/design-context.ts +198 -0
- package/src/dispatcher.test.ts +234 -0
- package/src/dispatcher.ts +189 -0
- package/src/durable-turn.test.ts +209 -0
- package/src/durable-turn.ts +135 -0
- package/src/edit-planner.test.ts +204 -0
- package/src/edit-planner.ts +325 -0
- package/src/fuzzy-match.test.ts +311 -0
- package/src/fuzzy-match.ts +444 -0
- package/src/hook-pipeline.test.ts +279 -0
- package/src/hook-pipeline.ts +373 -0
- package/src/inbound-admission.test.ts +394 -0
- package/src/inbound-admission.ts +246 -0
- package/src/index.ts +47 -0
- package/src/loop.test.ts +161 -0
- package/src/loop.ts +211 -0
- package/src/mcp-bridge.test.ts +165 -0
- package/src/mcp-bridge.ts +76 -0
- package/src/memory-provider.test.ts +232 -0
- package/src/memory-provider.ts +257 -0
- package/src/model.ts +168 -0
- package/src/permission-ruleset.test.ts +301 -0
- package/src/permission-ruleset.ts +200 -0
- package/src/policy.ts +151 -0
- package/src/project-repo.test.ts +232 -0
- package/src/project-repo.ts +311 -0
- package/src/protocol.ts +159 -0
- package/src/rollout-store-persistent.test.ts +217 -0
- package/src/rollout-store-persistent.ts +166 -0
- package/src/rollout.ts +150 -0
- package/src/sandbox.ts +113 -0
- package/src/session-share.test.ts +360 -0
- package/src/session-share.ts +310 -0
- package/src/skill-distillation.test.ts +177 -0
- package/src/skill-distillation.ts +369 -0
- package/src/skills.test.ts +277 -0
- package/src/skills.ts +255 -0
- package/src/subagents.test.ts +290 -0
- package/src/subagents.ts +332 -0
- package/src/tools.ts +126 -0
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +33 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Definition, Frontmatter } from "./definitions";
|
|
3
|
+
import { DefinitionResolver } from "./definitions";
|
|
4
|
+
import type { ThreadItem, TurnUsage } from "./model";
|
|
5
|
+
import {
|
|
6
|
+
assertNotPeeking,
|
|
7
|
+
prepareDispatch,
|
|
8
|
+
resolveSubagentTools,
|
|
9
|
+
type SubagentRecord,
|
|
10
|
+
settleDeferred,
|
|
11
|
+
TaskRegistry,
|
|
12
|
+
} from "./subagents";
|
|
13
|
+
|
|
14
|
+
const fm = (over: Partial<Frontmatter> = {}): Frontmatter => ({
|
|
15
|
+
name: "researcher",
|
|
16
|
+
description: "",
|
|
17
|
+
allowedTools: [],
|
|
18
|
+
disallowedTools: [],
|
|
19
|
+
argNames: [],
|
|
20
|
+
modelInvocable: true,
|
|
21
|
+
userInvocable: true,
|
|
22
|
+
executionMode: "fork",
|
|
23
|
+
paths: [],
|
|
24
|
+
...over,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const def = (over: Partial<Frontmatter> = {}, slug = "researcher"): SubagentRecord => ({
|
|
28
|
+
slug,
|
|
29
|
+
tenantId: "org_1",
|
|
30
|
+
sourceTier: "workspace",
|
|
31
|
+
frontmatter: fm(over),
|
|
32
|
+
bodyRef: "ref://researcher",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const usage: TurnUsage = {
|
|
36
|
+
inputTokens: 10,
|
|
37
|
+
cachedInputTokens: 0,
|
|
38
|
+
outputTokens: 5,
|
|
39
|
+
reasoningOutputTokens: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const msg = (id: string, text: string): ThreadItem => ({ id, type: "agent_message", text });
|
|
43
|
+
|
|
44
|
+
describe("resolveSubagentTools", () => {
|
|
45
|
+
it("computes allow minus deny", () => {
|
|
46
|
+
const tools = resolveSubagentTools(
|
|
47
|
+
fm({ allowedTools: ["read", "write", "exec"], disallowedTools: ["exec"] }),
|
|
48
|
+
["read", "write", "exec", "search"],
|
|
49
|
+
);
|
|
50
|
+
expect(tools).toEqual(["read", "write"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to all-except-deny when allow empty", () => {
|
|
54
|
+
const tools = resolveSubagentTools(fm({ allowedTools: [], disallowedTools: ["exec"] }), [
|
|
55
|
+
"read",
|
|
56
|
+
"write",
|
|
57
|
+
"exec",
|
|
58
|
+
]);
|
|
59
|
+
expect(tools).toEqual(["read", "write"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("intersects allow with the universe (no phantom tools)", () => {
|
|
63
|
+
const tools = resolveSubagentTools(fm({ allowedTools: ["read", "ghost"] }), ["read", "write"]);
|
|
64
|
+
expect(tools).toEqual(["read"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("prepareDispatch spawn", () => {
|
|
69
|
+
it("throws when brief missing in spawn mode", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
prepareDispatch({
|
|
72
|
+
definition: def(),
|
|
73
|
+
mode: "spawn",
|
|
74
|
+
brief: "",
|
|
75
|
+
ctx: { tenantId: "org_1" },
|
|
76
|
+
}),
|
|
77
|
+
).toThrow(/brief/i);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("starts with zero inherited context", () => {
|
|
81
|
+
const parentItems = [msg("p1", "parent secret")];
|
|
82
|
+
const out = prepareDispatch({
|
|
83
|
+
definition: def({ allowedTools: ["read"] }),
|
|
84
|
+
mode: "spawn",
|
|
85
|
+
parentItems,
|
|
86
|
+
brief: "do the thing",
|
|
87
|
+
ctx: { tenantId: "org_1" },
|
|
88
|
+
allTools: ["read", "write"],
|
|
89
|
+
});
|
|
90
|
+
expect(out.initialContext).toHaveLength(1);
|
|
91
|
+
expect(out.initialContext[0]).toMatchObject({ type: "agent_message" });
|
|
92
|
+
expect(JSON.stringify(out.initialContext)).not.toContain("parent secret");
|
|
93
|
+
expect(out.toolScope).toEqual(["read"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("passes model override through", () => {
|
|
97
|
+
const out = prepareDispatch({
|
|
98
|
+
definition: def({ model: "fast-tier" }),
|
|
99
|
+
mode: "spawn",
|
|
100
|
+
brief: "go",
|
|
101
|
+
ctx: { tenantId: "org_1" },
|
|
102
|
+
});
|
|
103
|
+
expect(out.modelOverride).toBe("fast-tier");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("prepareDispatch fork", () => {
|
|
108
|
+
it("inherits parentItems with incomplete tool calls filtered", () => {
|
|
109
|
+
const parentItems: ThreadItem[] = [
|
|
110
|
+
msg("a", "hello"),
|
|
111
|
+
{
|
|
112
|
+
id: "tc1",
|
|
113
|
+
type: "mcp_tool_call",
|
|
114
|
+
server: "s",
|
|
115
|
+
tool: "t",
|
|
116
|
+
arguments: {},
|
|
117
|
+
status: "completed",
|
|
118
|
+
},
|
|
119
|
+
msg("b", "world"),
|
|
120
|
+
{
|
|
121
|
+
id: "tc2",
|
|
122
|
+
type: "mcp_tool_call",
|
|
123
|
+
server: "s",
|
|
124
|
+
tool: "t",
|
|
125
|
+
arguments: {},
|
|
126
|
+
status: "in_progress",
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
const snapshot = JSON.stringify(parentItems);
|
|
130
|
+
const out = prepareDispatch({
|
|
131
|
+
definition: def(),
|
|
132
|
+
mode: "fork",
|
|
133
|
+
parentItems,
|
|
134
|
+
brief: "continue",
|
|
135
|
+
ctx: { tenantId: "org_1" },
|
|
136
|
+
});
|
|
137
|
+
const ids = out.initialContext.map((i) => i.id);
|
|
138
|
+
expect(ids).toContain("a");
|
|
139
|
+
expect(ids).toContain("tc1");
|
|
140
|
+
expect(ids).toContain("b");
|
|
141
|
+
expect(ids).not.toContain("tc2");
|
|
142
|
+
// brief appended as a directive
|
|
143
|
+
expect(out.initialContext.at(-1)).toMatchObject({ type: "agent_message" });
|
|
144
|
+
// parentItems untouched
|
|
145
|
+
expect(JSON.stringify(parentItems)).toBe(snapshot);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("fork without parentItems yields just the directive", () => {
|
|
149
|
+
const out = prepareDispatch({
|
|
150
|
+
definition: def(),
|
|
151
|
+
mode: "fork",
|
|
152
|
+
brief: "lone directive",
|
|
153
|
+
ctx: { tenantId: "org_1" },
|
|
154
|
+
});
|
|
155
|
+
expect(out.initialContext).toHaveLength(1);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("DispatchEnvelope sync normalization", () => {
|
|
160
|
+
it("normalizes empty content to an explicit marker item", () => {
|
|
161
|
+
const reg = new TaskRegistry();
|
|
162
|
+
const id = reg.create("org_1", "subagent");
|
|
163
|
+
reg.transition(id, "running", "org_1");
|
|
164
|
+
const env = settleDeferred(
|
|
165
|
+
{ taskId: id, channel: "ch", tenantId: "org_1", registry: reg },
|
|
166
|
+
[],
|
|
167
|
+
usage,
|
|
168
|
+
);
|
|
169
|
+
expect(env.kind).toBe("sync");
|
|
170
|
+
if (env.kind === "sync") {
|
|
171
|
+
expect(env.content).toHaveLength(1);
|
|
172
|
+
expect(env.content[0]).toMatchObject({ type: "error" });
|
|
173
|
+
expect(env.agentId).toBe(id);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("settleDeferred yields sync shape with metrics", () => {
|
|
178
|
+
const reg = new TaskRegistry();
|
|
179
|
+
const id = reg.create("org_1", "subagent");
|
|
180
|
+
reg.transition(id, "running", "org_1");
|
|
181
|
+
const env = settleDeferred(
|
|
182
|
+
{ taskId: id, channel: "ch", tenantId: "org_1", registry: reg },
|
|
183
|
+
[
|
|
184
|
+
msg("r1", "result"),
|
|
185
|
+
{
|
|
186
|
+
id: "u1",
|
|
187
|
+
type: "mcp_tool_call",
|
|
188
|
+
server: "s",
|
|
189
|
+
tool: "t",
|
|
190
|
+
arguments: {},
|
|
191
|
+
status: "completed",
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
usage,
|
|
195
|
+
);
|
|
196
|
+
expect(env.kind).toBe("sync");
|
|
197
|
+
if (env.kind === "sync") {
|
|
198
|
+
expect(env.content).toHaveLength(2);
|
|
199
|
+
expect(env.toolUses).toBe(1);
|
|
200
|
+
expect(env.usage).toEqual(usage);
|
|
201
|
+
expect(env.durationMs).toBeGreaterThanOrEqual(0);
|
|
202
|
+
}
|
|
203
|
+
expect(reg.get(id, "org_1")?.status).toBe("completed");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("assertNotPeeking", () => {
|
|
208
|
+
it("throws while task is in-flight", () => {
|
|
209
|
+
const reg = new TaskRegistry();
|
|
210
|
+
const id = reg.create("org_1", "subagent");
|
|
211
|
+
reg.transition(id, "running", "org_1");
|
|
212
|
+
expect(() => assertNotPeeking(reg.get(id, "org_1")!)).toThrow(/in-flight|peek/i);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("does not throw once settled", () => {
|
|
216
|
+
const reg = new TaskRegistry();
|
|
217
|
+
const id = reg.create("org_1", "subagent");
|
|
218
|
+
reg.transition(id, "running", "org_1");
|
|
219
|
+
reg.transition(id, "completed", "org_1");
|
|
220
|
+
expect(() => assertNotPeeking(reg.get(id, "org_1")!)).not.toThrow();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("TaskRegistry state machine + tenancy", () => {
|
|
225
|
+
it("creates prefixed ids", () => {
|
|
226
|
+
const reg = new TaskRegistry();
|
|
227
|
+
const id = reg.create("org_1", "subagent");
|
|
228
|
+
expect(id.startsWith("task_")).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("allows legal transitions", () => {
|
|
232
|
+
const reg = new TaskRegistry();
|
|
233
|
+
const id = reg.create("org_1", "subagent");
|
|
234
|
+
reg.transition(id, "running", "org_1");
|
|
235
|
+
reg.transition(id, "completed", "org_1");
|
|
236
|
+
expect(reg.get(id, "org_1")?.status).toBe("completed");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("rejects illegal transitions", () => {
|
|
240
|
+
const reg = new TaskRegistry();
|
|
241
|
+
const id = reg.create("org_1", "subagent");
|
|
242
|
+
reg.transition(id, "running", "org_1");
|
|
243
|
+
reg.transition(id, "completed", "org_1");
|
|
244
|
+
expect(() => reg.transition(id, "running", "org_1")).toThrow(/transition/i);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("stop() kills a task", () => {
|
|
248
|
+
const reg = new TaskRegistry();
|
|
249
|
+
const id = reg.create("org_1", "subagent");
|
|
250
|
+
reg.transition(id, "running", "org_1");
|
|
251
|
+
reg.stop(id, "org_1");
|
|
252
|
+
expect(reg.get(id, "org_1")?.status).toBe("killed");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("fails closed on cross-tenant access", () => {
|
|
256
|
+
const reg = new TaskRegistry();
|
|
257
|
+
const id = reg.create("org_1", "subagent");
|
|
258
|
+
expect(() => reg.transition(id, "running", "org_2")).toThrow(/tenant/i);
|
|
259
|
+
expect(() => reg.stop(id, "org_2")).toThrow(/tenant/i);
|
|
260
|
+
expect(reg.get(id, "org_2")).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("throws on empty tenant", () => {
|
|
264
|
+
const reg = new TaskRegistry();
|
|
265
|
+
expect(() => reg.create("", "subagent")).toThrow(/tenant/i);
|
|
266
|
+
const id = reg.create("org_1", "subagent");
|
|
267
|
+
expect(() => reg.transition(id, "running", "")).toThrow(/tenant/i);
|
|
268
|
+
expect(() => reg.get(id, "")).toThrow(/tenant/i);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("prepareDispatch fails closed on tenant mismatch", () => {
|
|
272
|
+
const d: SubagentRecord = def();
|
|
273
|
+
expect(() =>
|
|
274
|
+
prepareDispatch({
|
|
275
|
+
definition: d,
|
|
276
|
+
mode: "spawn",
|
|
277
|
+
brief: "x",
|
|
278
|
+
ctx: { tenantId: "org_2" },
|
|
279
|
+
}),
|
|
280
|
+
).toThrow(/tenant/i);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("resolver-backed registry is reusable as a DefinitionResolver", () => {
|
|
284
|
+
const resolver = new DefinitionResolver<SubagentRecord>([def(), def({}, "writer")]);
|
|
285
|
+
const resolved = resolver.resolveOne("researcher", { tenantId: "org_1" });
|
|
286
|
+
expect(resolved?.slug).toBe("researcher");
|
|
287
|
+
const d: Definition | undefined = resolved;
|
|
288
|
+
expect(d).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
});
|
package/src/subagents.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent / task dispatch (WRAP — isolated-child-agent model, the delta).
|
|
3
|
+
*
|
|
4
|
+
* Faithful re-expression of the source harness's child-agent dispatch grammar:
|
|
5
|
+
* a registry decides WHICH definition runs, a context-boundary contract decides
|
|
6
|
+
* WITH WHAT inherited context it starts, and a typed terminal envelope decides
|
|
7
|
+
* HOW its result re-enters the parent. The child loop, child policy, and
|
|
8
|
+
* rollout store live elsewhere in agent-runtime and are intentionally NOT
|
|
9
|
+
* reimplemented here — this layer is pure decision + data + state machine.
|
|
10
|
+
*
|
|
11
|
+
* Multi-tenant: every task operation is keyed by `tenantId` and fails closed.
|
|
12
|
+
* Pure data/logic — no FS scan, no shell, no network.
|
|
13
|
+
*
|
|
14
|
+
* NOTE on dropped task kinds: the source harness models several local execution
|
|
15
|
+
* task kinds (local_bash / monitor_mcp / worktree). Those are deliberately NOT
|
|
16
|
+
* represented here: this package never executes locally — execution is delegated
|
|
17
|
+
* via the external-sandbox seam — so only the agent-facing `subagent` kind (and
|
|
18
|
+
* its `deferred` sibling) is modelled.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import type { Definition, Frontmatter } from "./definitions";
|
|
23
|
+
import type { ThreadItem, TurnUsage } from "./model";
|
|
24
|
+
|
|
25
|
+
/* ───────────────────────── Subagent definition ─────────────────────────── */
|
|
26
|
+
|
|
27
|
+
/** A subagent definition is just a `Definition` (same loader as skills/commands). */
|
|
28
|
+
export type SubagentRecord = Definition;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Effective tool set for a subagent: `allowedTools` minus `disallowedTools`,
|
|
32
|
+
* intersected with the runtime tool universe so a definition can never widen
|
|
33
|
+
* its scope past what the host exposes. When `allowedTools` is empty the
|
|
34
|
+
* contract is "all tools except the denied ones" (all-except-deny).
|
|
35
|
+
*
|
|
36
|
+
* Pure — order follows `allTools` for determinism.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSubagentTools(fm: Frontmatter, allTools: readonly string[]): string[] {
|
|
39
|
+
const deny = new Set(fm.disallowedTools);
|
|
40
|
+
const allow = fm.allowedTools;
|
|
41
|
+
if (allow.length === 0) {
|
|
42
|
+
return allTools.filter((t) => !deny.has(t));
|
|
43
|
+
}
|
|
44
|
+
const allowSet = new Set(allow);
|
|
45
|
+
return allTools.filter((t) => allowSet.has(t) && !deny.has(t));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ───────────────────── Context-boundary contract ───────────────────────── */
|
|
49
|
+
|
|
50
|
+
export type DispatchMode = "spawn" | "fork";
|
|
51
|
+
|
|
52
|
+
export interface DispatchCtx {
|
|
53
|
+
readonly tenantId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PrepareDispatchInput {
|
|
57
|
+
readonly definition: SubagentRecord;
|
|
58
|
+
readonly mode: DispatchMode;
|
|
59
|
+
readonly parentItems?: readonly ThreadItem[];
|
|
60
|
+
/** Caller's directive. Mandatory & non-empty in `spawn` mode. */
|
|
61
|
+
readonly brief: string;
|
|
62
|
+
readonly ctx: DispatchCtx;
|
|
63
|
+
/** Runtime tool universe; defaults to the definition's own allow list. */
|
|
64
|
+
readonly allTools?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PreparedDispatch {
|
|
68
|
+
readonly initialContext: readonly ThreadItem[];
|
|
69
|
+
readonly toolScope: readonly string[];
|
|
70
|
+
/** Opaque reference to the system prompt body (resolved by the child loop). */
|
|
71
|
+
readonly systemPromptRef: string;
|
|
72
|
+
readonly modelOverride?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True for a trailing tool-call item that never received a terminal result. */
|
|
76
|
+
function isIncompleteToolCall(item: ThreadItem): boolean {
|
|
77
|
+
if (item.type === "mcp_tool_call" || item.type === "command_execution") {
|
|
78
|
+
return item.status === "in_progress";
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drop trailing incomplete tool-call items so a forked child never inherits a
|
|
85
|
+
* dangling tool_use without its result. Pure — input array is never mutated.
|
|
86
|
+
*/
|
|
87
|
+
function filterIncompleteToolCalls(items: readonly ThreadItem[]): ThreadItem[] {
|
|
88
|
+
return items.filter((it) => !isIncompleteToolCall(it));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function directiveItem(idSeed: string, brief: string): ThreadItem {
|
|
92
|
+
return { id: `brief_${idSeed}`, type: "agent_message", text: brief };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decide the child's starting context boundary.
|
|
97
|
+
*
|
|
98
|
+
* - `spawn`: zero inherited context. The caller MUST fully brief the child;
|
|
99
|
+
* an empty brief is a programming error and throws.
|
|
100
|
+
* - `fork`: inherit a defensive copy of `parentItems` with incomplete tool
|
|
101
|
+
* calls filtered out, then append the brief as a directive.
|
|
102
|
+
*
|
|
103
|
+
* `parentItems` is never mutated. Cross-tenant dispatch fails closed.
|
|
104
|
+
*/
|
|
105
|
+
export function prepareDispatch(input: PrepareDispatchInput): PreparedDispatch {
|
|
106
|
+
const { definition, mode, parentItems, brief, ctx, allTools } = input;
|
|
107
|
+
if (!ctx.tenantId) throw new Error("DispatchCtx.tenantId is required");
|
|
108
|
+
if (definition.tenantId !== ctx.tenantId) {
|
|
109
|
+
throw new Error("subagent definition belongs to a different tenant (fail closed)");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const trimmedBrief = brief.trim();
|
|
113
|
+
const fm = definition.frontmatter;
|
|
114
|
+
const toolScope = resolveSubagentTools(fm, allTools ?? fm.allowedTools);
|
|
115
|
+
|
|
116
|
+
let initialContext: ThreadItem[];
|
|
117
|
+
if (mode === "spawn") {
|
|
118
|
+
if (!trimmedBrief) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"spawn dispatch requires a non-empty brief — caller must fully brief the child",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
initialContext = [directiveItem(definition.slug, trimmedBrief)];
|
|
124
|
+
} else {
|
|
125
|
+
const inherited = parentItems ? filterIncompleteToolCalls(parentItems) : [];
|
|
126
|
+
initialContext = trimmedBrief
|
|
127
|
+
? [...inherited, directiveItem(definition.slug, trimmedBrief)]
|
|
128
|
+
: [...inherited];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const modelOverride = fm.model && fm.model !== "inherit" ? fm.model : undefined;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
initialContext,
|
|
135
|
+
toolScope,
|
|
136
|
+
systemPromptRef: definition.bodyRef,
|
|
137
|
+
...(modelOverride ? { modelOverride } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ─────────────────── Uniform Task lifecycle registry ───────────────────── */
|
|
142
|
+
|
|
143
|
+
export const TASK_KINDS = ["subagent", "deferred"] as const;
|
|
144
|
+
export type TaskKind = (typeof TASK_KINDS)[number];
|
|
145
|
+
|
|
146
|
+
export const TASK_STATUSES = ["pending", "running", "completed", "failed", "killed"] as const;
|
|
147
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
148
|
+
|
|
149
|
+
export interface TaskRecord {
|
|
150
|
+
readonly taskId: string;
|
|
151
|
+
readonly tenantId: string;
|
|
152
|
+
readonly status: TaskStatus;
|
|
153
|
+
readonly notified: boolean;
|
|
154
|
+
readonly kind: TaskKind;
|
|
155
|
+
readonly createdAtMs: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Legal status transitions. Any pair absent here is rejected. */
|
|
159
|
+
const LEGAL: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
|
|
160
|
+
pending: ["running", "killed", "failed"],
|
|
161
|
+
running: ["completed", "failed", "killed"],
|
|
162
|
+
completed: [],
|
|
163
|
+
failed: [],
|
|
164
|
+
killed: [],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const TERMINAL: ReadonlySet<TaskStatus> = new Set(["completed", "failed", "killed"]);
|
|
168
|
+
|
|
169
|
+
let __seq = 0;
|
|
170
|
+
function nextTaskId(): string {
|
|
171
|
+
__seq += 1;
|
|
172
|
+
return `task_${Date.now().toString(36)}_${__seq.toString(36)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireTenant(tenantId: string): void {
|
|
176
|
+
if (!tenantId) throw new Error("tenantId is required (fail closed)");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Tenant-scoped task registry. Every mutating op verifies the caller's
|
|
181
|
+
* tenant against the stored record; a mismatch is treated as "not found"
|
|
182
|
+
* for reads and throws for mutations — cross-tenant access is impossible.
|
|
183
|
+
*/
|
|
184
|
+
export class TaskRegistry {
|
|
185
|
+
readonly #tasks = new Map<string, TaskRecord>();
|
|
186
|
+
|
|
187
|
+
create(tenantId: string, kind: TaskKind): string {
|
|
188
|
+
requireTenant(tenantId);
|
|
189
|
+
const taskId = nextTaskId();
|
|
190
|
+
this.#tasks.set(taskId, {
|
|
191
|
+
taskId,
|
|
192
|
+
tenantId,
|
|
193
|
+
status: "pending",
|
|
194
|
+
notified: false,
|
|
195
|
+
kind,
|
|
196
|
+
createdAtMs: Date.now(),
|
|
197
|
+
});
|
|
198
|
+
return taskId;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Tenant-checked read. Returns undefined for unknown or cross-tenant ids. */
|
|
202
|
+
get(taskId: string, tenantId: string): TaskRecord | undefined {
|
|
203
|
+
requireTenant(tenantId);
|
|
204
|
+
const rec = this.#tasks.get(taskId);
|
|
205
|
+
if (!rec || rec.tenantId !== tenantId) return undefined;
|
|
206
|
+
return rec;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#ownedOrThrow(taskId: string, tenantId: string): TaskRecord {
|
|
210
|
+
requireTenant(tenantId);
|
|
211
|
+
const rec = this.#tasks.get(taskId);
|
|
212
|
+
if (!rec) throw new Error(`task ${taskId} not found`);
|
|
213
|
+
if (rec.tenantId !== tenantId) {
|
|
214
|
+
throw new Error("cross-tenant task access denied (fail closed)");
|
|
215
|
+
}
|
|
216
|
+
return rec;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
transition(taskId: string, to: TaskStatus, tenantId: string): TaskRecord {
|
|
220
|
+
const rec = this.#ownedOrThrow(taskId, tenantId);
|
|
221
|
+
const allowed = LEGAL[rec.status];
|
|
222
|
+
if (!allowed.includes(to)) {
|
|
223
|
+
throw new Error(`illegal task transition ${rec.status} → ${to}`);
|
|
224
|
+
}
|
|
225
|
+
const next: TaskRecord = { ...rec, status: to };
|
|
226
|
+
this.#tasks.set(taskId, next);
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Unified kill — drives any non-terminal task to `killed`. */
|
|
231
|
+
stop(taskId: string, tenantId: string): TaskRecord {
|
|
232
|
+
const rec = this.#ownedOrThrow(taskId, tenantId);
|
|
233
|
+
if (TERMINAL.has(rec.status)) return rec;
|
|
234
|
+
return this.transition(taskId, "killed", tenantId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
markNotified(taskId: string, tenantId: string): TaskRecord {
|
|
238
|
+
const rec = this.#ownedOrThrow(taskId, tenantId);
|
|
239
|
+
const next: TaskRecord = { ...rec, notified: true };
|
|
240
|
+
this.#tasks.set(taskId, next);
|
|
241
|
+
return next;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* No-peek / no-race guard. Throws if a parent tries to read an in-flight
|
|
247
|
+
* deferred task's transcript before it has settled.
|
|
248
|
+
*/
|
|
249
|
+
export function assertNotPeeking(task: TaskRecord): void {
|
|
250
|
+
if (!TERMINAL.has(task.status)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`cannot peek at in-flight task ${task.taskId} (status=${task.status}); await settlement`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ─────────────── Typed terminal-envelope result contract ────────────────── */
|
|
258
|
+
|
|
259
|
+
export interface SyncEnvelope {
|
|
260
|
+
readonly kind: "sync";
|
|
261
|
+
readonly content: readonly ThreadItem[];
|
|
262
|
+
readonly usage: TurnUsage;
|
|
263
|
+
readonly agentId: string;
|
|
264
|
+
readonly toolUses: number;
|
|
265
|
+
readonly durationMs: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface DeferredEnvelope {
|
|
269
|
+
readonly kind: "deferred";
|
|
270
|
+
readonly taskId: string;
|
|
271
|
+
readonly channel: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export type DispatchEnvelope = SyncEnvelope | DeferredEnvelope;
|
|
275
|
+
|
|
276
|
+
const NO_OUTPUT_MARKER: ThreadItem = {
|
|
277
|
+
id: "subagent_no_output",
|
|
278
|
+
type: "error",
|
|
279
|
+
message: "subagent completed but returned no output",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const threadItemShape = z.object({ id: z.string(), type: z.string() });
|
|
283
|
+
|
|
284
|
+
/** Empty content is never silently empty — normalize to one explicit marker. */
|
|
285
|
+
function normalizeContent(content: readonly ThreadItem[]): readonly ThreadItem[] {
|
|
286
|
+
for (const item of content) threadItemShape.parse(item);
|
|
287
|
+
return content.length === 0 ? [NO_OUTPUT_MARKER] : content;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function countToolUses(content: readonly ThreadItem[]): number {
|
|
291
|
+
return content.filter((i) => i.type === "mcp_tool_call" || i.type === "command_execution").length;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Build a fire-and-forget deferred envelope for a registered task. */
|
|
295
|
+
export function makeDeferred(taskId: string, channel: string): DeferredEnvelope {
|
|
296
|
+
if (!channel) throw new Error("deferred envelope requires a delivery channel");
|
|
297
|
+
return { kind: "deferred", taskId, channel };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface SettleDeferredTarget {
|
|
301
|
+
readonly taskId: string;
|
|
302
|
+
readonly channel: string;
|
|
303
|
+
readonly tenantId: string;
|
|
304
|
+
readonly registry: TaskRegistry;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Settle a previously-deferred task: drive it to `completed` (tenant-checked)
|
|
309
|
+
* and produce the eventual sync-shaped result. Empty content is normalized to
|
|
310
|
+
* the explicit no-output marker.
|
|
311
|
+
*/
|
|
312
|
+
export function settleDeferred(
|
|
313
|
+
target: SettleDeferredTarget,
|
|
314
|
+
content: readonly ThreadItem[],
|
|
315
|
+
usage: TurnUsage,
|
|
316
|
+
): SyncEnvelope {
|
|
317
|
+
const { taskId, tenantId, registry } = target;
|
|
318
|
+
const rec = registry.get(taskId, tenantId);
|
|
319
|
+
if (!rec) throw new Error("cannot settle unknown or cross-tenant task (fail closed)");
|
|
320
|
+
if (!TERMINAL.has(rec.status)) {
|
|
321
|
+
registry.transition(taskId, "completed", tenantId);
|
|
322
|
+
}
|
|
323
|
+
const normalized = normalizeContent(content);
|
|
324
|
+
return {
|
|
325
|
+
kind: "sync",
|
|
326
|
+
content: normalized,
|
|
327
|
+
usage,
|
|
328
|
+
agentId: taskId,
|
|
329
|
+
toolUses: countToolUses(normalized),
|
|
330
|
+
durationMs: Math.max(0, Date.now() - rec.createdAtMs),
|
|
331
|
+
};
|
|
332
|
+
}
|