@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
package/src/dispatcher.ts DELETED
@@ -1,189 +0,0 @@
1
- /**
2
- * Transport-agnostic JSON-RPC dispatcher for the app-server protocol.
3
- *
4
- * Pure and testable: no sockets. WS/SSE/stdio adapters wrap this dispatcher
5
- * and are intentionally out of scope here.
6
- *
7
- * Faithful concurrency model (see `protocol.ts`): requests sharing a
8
- * `scopeKey` are serialized FIFO; distinct scope keys run concurrently.
9
- * Because every scope is tenant-prefixed, cross-tenant requests can never
10
- * share a serial lane (fail-closed multi-tenancy by construction).
11
- */
12
-
13
- import {
14
- METHOD_REGISTRY,
15
- type MethodSpec,
16
- NOTIFICATIONS,
17
- type NotificationName,
18
- requestEnvelopeSchema,
19
- resolveScope,
20
- type SerializationScope,
21
- scopeKey,
22
- } from "./protocol";
23
-
24
- // ── Public types ─────────────────────────────────────────────────────────────
25
-
26
- export type MethodHandler = (params: unknown, scope: SerializationScope) => Promise<unknown>;
27
-
28
- export type NotificationListener = (payload: unknown) => void;
29
-
30
- interface JsonRpcSuccess {
31
- readonly jsonrpc: "2.0";
32
- readonly id: string | number | null;
33
- readonly result: unknown;
34
- }
35
-
36
- interface JsonRpcFailure {
37
- readonly jsonrpc: "2.0";
38
- readonly id: string | number | null;
39
- readonly error: { readonly code: number; readonly message: string };
40
- }
41
-
42
- export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
43
-
44
- // ── JSON-RPC error codes (spec-aligned) ──────────────────────────────────────
45
-
46
- const ERR_INVALID_REQUEST = -32600;
47
- const ERR_METHOD_NOT_FOUND = -32601;
48
- const ERR_INVALID_PARAMS = -32602;
49
- const ERR_INTERNAL = -32603;
50
-
51
- function success(id: string | number | null, result: unknown): JsonRpcSuccess {
52
- return { jsonrpc: "2.0", id, result };
53
- }
54
-
55
- function failure(id: string | number | null, code: number, message: string): JsonRpcFailure {
56
- return { jsonrpc: "2.0", id, error: { code, message } };
57
- }
58
-
59
- const NOTIFICATION_NAMES: ReadonlySet<string> = new Set(NOTIFICATIONS);
60
-
61
- const METHOD_SPEC_BY_WIRE: ReadonlyMap<string, MethodSpec> = new Map(
62
- Object.values(METHOD_REGISTRY).map((spec) => [spec.method, spec]),
63
- );
64
-
65
- /** Recover a best-effort id from an unvalidated payload for error responses. */
66
- function extractId(raw: unknown): string | number | null {
67
- if (raw && typeof raw === "object" && "id" in raw) {
68
- const id = (raw as { id: unknown }).id;
69
- if (typeof id === "string" || typeof id === "number") return id;
70
- }
71
- return null;
72
- }
73
-
74
- export class ProtocolDispatcher {
75
- readonly #handlers = new Map<string, MethodHandler>();
76
- readonly #notificationListeners = new Map<NotificationName, Set<NotificationListener>>();
77
- /** Per-scope-key serial promise chains (tail-of-lane). */
78
- readonly #lanes = new Map<string, Promise<void>>();
79
-
80
- /** Register a handler for a wire method name (e.g. `"turn/start"`). */
81
- register(methodName: string, handler: MethodHandler): this {
82
- this.#handlers.set(methodName, handler);
83
- return this;
84
- }
85
-
86
- /** Subscribe to a server→client notification. */
87
- onNotification(name: NotificationName, listener: NotificationListener): () => void {
88
- let set = this.#notificationListeners.get(name);
89
- if (!set) {
90
- set = new Set();
91
- this.#notificationListeners.set(name, set);
92
- }
93
- set.add(listener);
94
- return () => {
95
- set?.delete(listener);
96
- };
97
- }
98
-
99
- /**
100
- * Emit a server→client notification. Validates the name against the
101
- * `NOTIFICATIONS` allow-list (fail-closed: unknown name throws).
102
- */
103
- emitNotification(name: NotificationName, payload: unknown): void {
104
- if (!NOTIFICATION_NAMES.has(name)) {
105
- throw new Error(`Unknown notification name: ${String(name)}`);
106
- }
107
- const listeners = this.#notificationListeners.get(name);
108
- if (!listeners) return;
109
- for (const listener of listeners) listener(payload);
110
- }
111
-
112
- /**
113
- * Dispatch a single raw envelope. Never throws — every failure path maps
114
- * to a JSON-RPC error response.
115
- */
116
- async dispatch(rawEnvelope: unknown): Promise<JsonRpcResponse> {
117
- const parsed = requestEnvelopeSchema.safeParse(rawEnvelope);
118
- if (!parsed.success) {
119
- return failure(
120
- extractId(rawEnvelope),
121
- ERR_INVALID_REQUEST,
122
- "Invalid request envelope (tenantId is mandatory)",
123
- );
124
- }
125
- const envelope = parsed.data;
126
-
127
- const spec = METHOD_SPEC_BY_WIRE.get(envelope.method);
128
- if (!spec) {
129
- return failure(envelope.id, ERR_METHOD_NOT_FOUND, `Method not found: ${envelope.method}`);
130
- }
131
-
132
- const handler = this.#handlers.get(envelope.method);
133
- if (!handler) {
134
- return failure(
135
- envelope.id,
136
- ERR_METHOD_NOT_FOUND,
137
- `No handler registered for method: ${envelope.method}`,
138
- );
139
- }
140
-
141
- const params: { threadId?: string } =
142
- envelope.params && typeof envelope.params === "object"
143
- ? (envelope.params as { threadId?: string })
144
- : {};
145
-
146
- let scope: SerializationScope;
147
- try {
148
- scope = resolveScope(spec, envelope.tenantId, params);
149
- } catch {
150
- return failure(
151
- envelope.id,
152
- ERR_INVALID_PARAMS,
153
- `Method ${envelope.method} requires a threadId for scoping`,
154
- );
155
- }
156
-
157
- const key = scopeKey(scope);
158
- return this.#runSerialized(key, async () => {
159
- try {
160
- const result = await handler(envelope.params, scope);
161
- return success(envelope.id, result);
162
- } catch (error) {
163
- const message = error instanceof Error ? error.message : "Internal handler error";
164
- return failure(envelope.id, ERR_INTERNAL, message);
165
- }
166
- });
167
- }
168
-
169
- /**
170
- * Run `task` after every previously-enqueued task on the same scope key.
171
- * A failing/slow task never poisons or blocks the lane permanently.
172
- */
173
- #runSerialized<T>(key: string, task: () => Promise<T>): Promise<T> {
174
- const previous = this.#lanes.get(key) ?? Promise.resolve();
175
- const run = previous.then(task, task);
176
- // Keep the lane's tail; swallow rejections in the chain link so a thrown
177
- // task does not break FIFO ordering for the next request.
178
- const tail = run.then(
179
- () => undefined,
180
- () => undefined,
181
- );
182
- this.#lanes.set(key, tail);
183
- // Garbage-collect the lane once drained to avoid unbounded key growth.
184
- void tail.then(() => {
185
- if (this.#lanes.get(key) === tail) this.#lanes.delete(key);
186
- });
187
- return run;
188
- }
189
- }
@@ -1,209 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { createDurableTurn, type DurableTurnQueuePort } from "./durable-turn";
4
- import type { ApprovalGate, ModelInvoker, ModelRoundResult } from "./loop";
5
- import { runTurn } from "./loop";
6
- import type { ThreadEvent, TurnConfig } from "./model";
7
- import type { ReviewDecision } from "./policy";
8
- import { InMemoryRolloutStore, type RolloutStore } from "./rollout";
9
- import { ToolRegistry } from "./tools";
10
-
11
- const config: TurnConfig = {
12
- model: "m",
13
- provider: "p",
14
- approvalPolicy: "on_request",
15
- capabilityPolicy: "external_sandbox",
16
- };
17
-
18
- const approveAll: ApprovalGate = {
19
- async request() {
20
- return { kind: "approved" } as ReviewDecision;
21
- },
22
- };
23
-
24
- /** Model that finishes immediately with text. */
25
- function quickModel(): ModelInvoker {
26
- return {
27
- async invoke(): Promise<ModelRoundResult> {
28
- return { emissions: [{ kind: "text", text: "done" }] };
29
- },
30
- };
31
- }
32
-
33
- function registry(): ToolRegistry {
34
- const reg = new ToolRegistry();
35
- reg.register(
36
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
37
- async (input: { v: string }) => input.v,
38
- );
39
- return reg;
40
- }
41
-
42
- /**
43
- * Fake in-process queue: records enqueues, runs handlers synchronously on
44
- * demand (so a test can simulate "crash" = never drain, then "resume").
45
- */
46
- function fakeQueue(): DurableTurnQueuePort & {
47
- pending: Map<string, () => Promise<void>>;
48
- drain: (jobId: string) => Promise<void>;
49
- } {
50
- const handlers = new Map<string, (payload: unknown) => Promise<void>>();
51
- const pending = new Map<string, () => Promise<void>>();
52
- return {
53
- pending,
54
- async enqueue(job) {
55
- const handler = handlers.get(job.name);
56
- if (!handler) throw new Error(`no handler for ${job.name}`);
57
- pending.set(job.jobId, () => handler(job.payload));
58
- },
59
- registerHandler(name, handler) {
60
- handlers.set(name, handler);
61
- },
62
- async drain(jobId) {
63
- const run = pending.get(jobId);
64
- if (!run) return;
65
- await run();
66
- pending.delete(jobId);
67
- },
68
- };
69
- }
70
-
71
- function runner(model: ModelInvoker, tools: ToolRegistry) {
72
- return (input: string, ctx: { tenantId: string; threadId: string }) =>
73
- runTurn(input, {
74
- tenantId: ctx.tenantId,
75
- threadId: ctx.threadId,
76
- config,
77
- approvalPolicy: { kind: "on_request" },
78
- model,
79
- tools,
80
- store: storeRef,
81
- approvalGate: approveAll,
82
- ruleEvaluator: () => "allow",
83
- });
84
- }
85
-
86
- let storeRef: RolloutStore;
87
-
88
- async function collect(gen: AsyncGenerator<ThreadEvent>): Promise<ThreadEvent[]> {
89
- const out: ThreadEvent[] = [];
90
- for await (const e of gen) out.push(e);
91
- return out;
92
- }
93
-
94
- describe("durable / resumable turn", () => {
95
- it("crash-then-resume continues an unfinished turn", async () => {
96
- const store = new InMemoryRolloutStore();
97
- storeRef = store;
98
- const queue = fakeQueue();
99
- const durable = createDurableTurn({
100
- store,
101
- queue,
102
- runner: runner(quickModel(), registry()),
103
- });
104
-
105
- const { turnId } = await durable.start("hello", {
106
- tenantId: "org_a",
107
- threadId: "th_1",
108
- });
109
- expect(turnId).toBeTruthy();
110
-
111
- // Simulate crash: job enqueued but never drained — no rollout yet.
112
- const before = await store.read("org_a", "th_1");
113
- expect(before.length).toBe(0);
114
-
115
- // Resume re-drives the unfinished turn to completion.
116
- const events = await collect(durable.resume("org_a", "th_1"));
117
- expect(events[events.length - 1]?.type).toBe("turn.completed");
118
-
119
- const lines = await store.read("org_a", "th_1");
120
- expect(lines.length).toBeGreaterThan(0);
121
- expect(lines.every((l) => l.tenantId === "org_a")).toBe(true);
122
- });
123
-
124
- it("resume of a completed turn replays recorded terminal events", async () => {
125
- const store = new InMemoryRolloutStore();
126
- storeRef = store;
127
- const queue = fakeQueue();
128
- const durable = createDurableTurn({
129
- store,
130
- queue,
131
- runner: runner(quickModel(), registry()),
132
- });
133
-
134
- const { turnId } = await durable.start("hello", {
135
- tenantId: "org_a",
136
- threadId: "th_done",
137
- });
138
- await queue.drain(turnId); // job ran to completion (no crash)
139
-
140
- const replayed = await collect(durable.resume("org_a", "th_done"));
141
- expect(replayed.some((e) => e.type === "turn.completed")).toBe(true);
142
-
143
- // Idempotent: a second resume replays the same terminal stream.
144
- const replayedAgain = await collect(durable.resume("org_a", "th_done"));
145
- expect(replayedAgain.map((e) => e.type)).toEqual(replayed.map((e) => e.type));
146
- // Resume must NOT append more lines when already terminal.
147
- const lines = await store.read("org_a", "th_done");
148
- const afterResume = await store.read("org_a", "th_done");
149
- expect(afterResume.length).toBe(lines.length);
150
- });
151
-
152
- it("cross-tenant resume is impossible (isolation)", async () => {
153
- const store = new InMemoryRolloutStore();
154
- storeRef = store;
155
- const queue = fakeQueue();
156
- const durable = createDurableTurn({
157
- store,
158
- queue,
159
- runner: runner(quickModel(), registry()),
160
- });
161
-
162
- const { turnId } = await durable.start("hello", {
163
- tenantId: "org_a",
164
- threadId: "th_secret",
165
- });
166
- await queue.drain(turnId);
167
-
168
- // Another tenant asking for the same threadId sees nothing of org_a's data.
169
- const events = await collect(durable.resume("org_b", "th_secret"));
170
- expect(events.some((e) => e.type === "turn.completed")).toBe(true);
171
- const orgB = await store.read("org_b", "th_secret");
172
- const orgA = await store.read("org_a", "th_secret");
173
- expect(orgB.every((l) => l.tenantId === "org_b")).toBe(true);
174
- expect(orgA.every((l) => l.tenantId === "org_a")).toBe(true);
175
- expect(orgA.length).not.toBe(0);
176
- });
177
-
178
- it("fails closed on missing tenant or thread", async () => {
179
- const store = new InMemoryRolloutStore();
180
- storeRef = store;
181
- const durable = createDurableTurn({
182
- store,
183
- queue: fakeQueue(),
184
- runner: runner(quickModel(), registry()),
185
- });
186
-
187
- await expect(durable.start("x", { tenantId: "", threadId: "th" })).rejects.toThrow();
188
- await expect(durable.start("x", { tenantId: "t", threadId: "" })).rejects.toThrow();
189
- await expect(collect(durable.resume("", "th"))).rejects.toThrow();
190
- await expect(collect(durable.resume("t", ""))).rejects.toThrow();
191
- });
192
-
193
- it("start enqueues a tenant+thread scoped jobId", async () => {
194
- const store = new InMemoryRolloutStore();
195
- storeRef = store;
196
- const queue = fakeQueue();
197
- const durable = createDurableTurn({
198
- store,
199
- queue,
200
- runner: runner(quickModel(), registry()),
201
- });
202
-
203
- const a = await durable.start("x", { tenantId: "org_a", threadId: "th_1" });
204
- const b = await durable.start("x", { tenantId: "org_b", threadId: "th_1" });
205
- expect(a.turnId).not.toBe(b.turnId);
206
- expect([...queue.pending.keys()]).toContain(a.turnId);
207
- expect([...queue.pending.keys()]).toContain(b.turnId);
208
- });
209
- });
@@ -1,135 +0,0 @@
1
- /**
2
- * Durable / resumable turn (WRAP — capability #10).
3
- *
4
- * Wraps the existing {@link runTurn} async generator so a turn survives a
5
- * process crash. `start` enqueues a tenant+thread-scoped durable job whose
6
- * handler drains `runTurn` (the loop runner already appends every event to the
7
- * tenant-scoped {@link RolloutStore}); the durable layer guarantees the job is
8
- * replayable if the worker dies mid-turn. `resume` inspects the persisted
9
- * rollout: if the last recorded event is non-terminal the turn is re-driven to
10
- * completion, otherwise the recorded terminal stream is replayed.
11
- *
12
- * Queue binding is interface-only — callers wire `@nebutra/queue` by
13
- * implementing {@link DurableTurnQueuePort}; this package never depends on it.
14
- * Tenant scoping is mandatory and fail-closed: a missing tenant or thread
15
- * throws, and resume can never read another tenant's rollout.
16
- */
17
-
18
- import { z } from "zod";
19
- import type { ThreadEvent } from "./model";
20
- import { isTurnTerminal } from "./model";
21
- import { type RolloutLine, type RolloutStore, replay } from "./rollout";
22
-
23
- /** A durable job handed to the queue. Mirrors `@nebutra/queue` job shape. */
24
- export interface DurableTurnJob {
25
- /** Logical job name — used for handler routing. */
26
- readonly name: string;
27
- /** Tenant+thread-scoped id; the queue must de-dupe / make idempotent on it. */
28
- readonly jobId: string;
29
- /** Opaque Zod-validated payload (kept narrow on purpose). */
30
- readonly payload: unknown;
31
- }
32
-
33
- /**
34
- * Minimal injectable queue seam (enqueue + handler registration). Mirrors
35
- * `@nebutra/queue`'s `defineQueueJob` / `createJobRouter` contract without a
36
- * hard dependency, so this package stays provider-agnostic.
37
- */
38
- export interface DurableTurnQueuePort {
39
- enqueue(job: DurableTurnJob): Promise<void>;
40
- registerHandler(name: string, handler: (payload: unknown) => Promise<void>): void;
41
- }
42
-
43
- /** Drives one turn, yielding the event stream (persists internally). */
44
- export type DurableTurnRunner = (input: string, ctx: TurnContext) => AsyncGenerator<ThreadEvent>;
45
-
46
- export interface TurnContext {
47
- readonly tenantId: string;
48
- readonly threadId: string;
49
- }
50
-
51
- export interface CreateDurableTurnDeps {
52
- readonly store: RolloutStore;
53
- readonly queue: DurableTurnQueuePort;
54
- readonly runner: DurableTurnRunner;
55
- }
56
-
57
- export interface DurableTurn {
58
- start(input: string, ctx: TurnContext): Promise<{ readonly turnId: string }>;
59
- resume(tenantId: string, threadId: string): AsyncGenerator<ThreadEvent>;
60
- }
61
-
62
- const DURABLE_TURN_JOB = "agent-runtime.durable-turn";
63
-
64
- const contextSchema = z.object({
65
- tenantId: z.string().min(1),
66
- threadId: z.string().min(1),
67
- });
68
-
69
- const jobPayloadSchema = z.object({
70
- tenantId: z.string().min(1),
71
- threadId: z.string().min(1),
72
- input: z.string(),
73
- });
74
-
75
- /** Tenant+thread-scoped, stable job id (idempotency key on the queue). */
76
- function turnIdFor(tenantId: string, threadId: string): string {
77
- return `${DURABLE_TURN_JOB}:${tenantId}:${threadId}`;
78
- }
79
-
80
- /** Recorded `ThreadEvent`s in append order, tenant-scoped via the store read. */
81
- function recordedEvents(lines: readonly RolloutLine[]): ThreadEvent[] {
82
- return lines.flatMap((line) => (line.type === "event" ? [line.event] : []));
83
- }
84
-
85
- export function createDurableTurn(deps: CreateDurableTurnDeps): DurableTurn {
86
- const { store, queue, runner } = deps;
87
-
88
- // Register the durable handler once: drain the runner so every event is
89
- // persisted by the loop runner. Crash before/within = job stays replayable.
90
- queue.registerHandler(DURABLE_TURN_JOB, async (payload: unknown) => {
91
- const { tenantId, threadId, input } = jobPayloadSchema.parse(payload);
92
- const gen = runner(input, { tenantId, threadId });
93
- for await (const _event of gen) {
94
- // The runner persists each event; nothing to do but exhaust it.
95
- }
96
- });
97
-
98
- async function start(input: string, ctx: TurnContext): Promise<{ readonly turnId: string }> {
99
- const { tenantId, threadId } = contextSchema.parse(ctx);
100
- const turnId = turnIdFor(tenantId, threadId);
101
- await queue.enqueue({
102
- name: DURABLE_TURN_JOB,
103
- jobId: turnId,
104
- payload: { tenantId, threadId, input },
105
- });
106
- return { turnId };
107
- }
108
-
109
- async function* resume(tenantId: string, threadId: string): AsyncGenerator<ThreadEvent> {
110
- const scope = contextSchema.parse({ tenantId, threadId });
111
- // Tenant-scoped read — cross-tenant resume is structurally impossible.
112
- const lines = await store.read(scope.tenantId, scope.threadId);
113
- const projection = replay(lines);
114
- const events = recordedEvents(lines);
115
- const last = events[events.length - 1];
116
-
117
- if (projection && last && isTurnTerminal(last)) {
118
- // Completed turn — replay the recorded stream, append nothing.
119
- for (const event of events) yield event;
120
- return;
121
- }
122
-
123
- // Unfinished (crash) or never-started — re-drive to completion. The runner
124
- // appends fresh lines; we recover the original input from the rollout if
125
- // present, else an empty resume prompt (turn is replay-driven thereafter).
126
- const input = "";
127
- const gen = runner(input, {
128
- tenantId: scope.tenantId,
129
- threadId: scope.threadId,
130
- });
131
- for await (const event of gen) yield event;
132
- }
133
-
134
- return { start, resume };
135
- }