@nebutra/agent-runtime 0.2.1 → 2.0.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.
Files changed (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
@@ -1,290 +0,0 @@
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 DELETED
@@ -1,332 +0,0 @@
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
- }
package/src/tools.test.ts DELETED
@@ -1,12 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { RuntimeToolRegistry, ToolRegistry } from "./tools";
3
-
4
- describe("runtime tool registry naming", () => {
5
- it("uses RuntimeToolRegistry as the canonical dispatcher name while preserving ToolRegistry compatibility", () => {
6
- const runtimeRegistry = new RuntimeToolRegistry();
7
- const compatRegistry = new ToolRegistry();
8
-
9
- expect(runtimeRegistry).toBeInstanceOf(RuntimeToolRegistry);
10
- expect(compatRegistry).toBeInstanceOf(RuntimeToolRegistry);
11
- });
12
- });