@nebutra/agent-runtime 0.2.0 → 0.2.2

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 (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.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
- }