@toren-run/core 0.1.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 (56) hide show
  1. package/LICENSE +202 -0
  2. package/dist/apiKeys.d.ts +28 -0
  3. package/dist/apiKeys.js +40 -0
  4. package/dist/approvals.d.ts +30 -0
  5. package/dist/approvals.js +54 -0
  6. package/dist/blobs.d.ts +12 -0
  7. package/dist/blobs.js +17 -0
  8. package/dist/builtins.d.ts +10 -0
  9. package/dist/builtins.js +244 -0
  10. package/dist/conversations.d.ts +38 -0
  11. package/dist/conversations.js +87 -0
  12. package/dist/db.d.ts +3 -0
  13. package/dist/db.js +20 -0
  14. package/dist/digest.d.ts +1 -0
  15. package/dist/digest.js +13 -0
  16. package/dist/events.d.ts +20 -0
  17. package/dist/events.js +4 -0
  18. package/dist/files.d.ts +28 -0
  19. package/dist/files.js +27 -0
  20. package/dist/fold.d.ts +3 -0
  21. package/dist/fold.js +6 -0
  22. package/dist/guardians.d.ts +9 -0
  23. package/dist/guardians.js +14 -0
  24. package/dist/index.d.ts +27 -0
  25. package/dist/index.js +27 -0
  26. package/dist/leases.d.ts +16 -0
  27. package/dist/leases.js +30 -0
  28. package/dist/loop.d.ts +69 -0
  29. package/dist/loop.js +413 -0
  30. package/dist/migrate.d.ts +4 -0
  31. package/dist/migrate.js +167 -0
  32. package/dist/model.d.ts +42 -0
  33. package/dist/model.js +1 -0
  34. package/dist/orchestrator.d.ts +35 -0
  35. package/dist/orchestrator.js +146 -0
  36. package/dist/providers/echo.d.ts +9 -0
  37. package/dist/providers/echo.js +17 -0
  38. package/dist/providers/mock.d.ts +12 -0
  39. package/dist/providers/mock.js +15 -0
  40. package/dist/queue.d.ts +68 -0
  41. package/dist/queue.js +54 -0
  42. package/dist/schedules.d.ts +61 -0
  43. package/dist/schedules.js +128 -0
  44. package/dist/spawn.d.ts +14 -0
  45. package/dist/spawn.js +113 -0
  46. package/dist/store.d.ts +50 -0
  47. package/dist/store.js +65 -0
  48. package/dist/tools.d.ts +98 -0
  49. package/dist/tools.js +14 -0
  50. package/dist/tracing.d.ts +6 -0
  51. package/dist/tracing.js +21 -0
  52. package/dist/worker.d.ts +34 -0
  53. package/dist/worker.js +176 -0
  54. package/dist/workflow.d.ts +91 -0
  55. package/dist/workflow.js +210 -0
  56. package/package.json +53 -0
package/dist/loop.js ADDED
@@ -0,0 +1,413 @@
1
+ import { canonicalDigest } from "./digest.js";
2
+ import { effectiveEvents } from "./fold.js";
3
+ import { ev } from "./events.js";
4
+ import { needsApproval, toolSpecs } from "./tools.js";
5
+ import { withSpan } from "./tracing.js";
6
+ export class TaskLeaseLostError extends Error {
7
+ }
8
+ /**
9
+ * Thrown instead of appending yet another StreamInvalidated when a stream has
10
+ * been invalidated repeatedly in a short window — the signature of two worker
11
+ * versions fighting over one stream during a rolling deploy, each re-paying
12
+ * the other's voided model calls. The worker defers the message instead; the
13
+ * war starves until one version drains, and a legitimate re-edit waits out
14
+ * the window at worst.
15
+ */
16
+ export class InvalidationStormError extends Error {
17
+ }
18
+ export const INVALIDATION_STORM_LIMIT = 3;
19
+ export const INVALIDATION_STORM_WINDOW_MS = 5 * 60 * 1000;
20
+ /** Provider defaults; agent.yaml `contextWindow:` overrides. mock/ gets none, so tests never compact by surprise. */
21
+ export function defaultContextWindow(model) {
22
+ if (model.startsWith("anthropic/"))
23
+ return 200_000;
24
+ if (model.startsWith("openai/"))
25
+ return 128_000;
26
+ return undefined;
27
+ }
28
+ // ---- context compaction constants. Changing these mid-run invalidates in-flight
29
+ // suffixes (same contract as changing a prompt); recorded compactions replay by value.
30
+ export const COMPACT_ELIDE_AT = 0.5; // of contextWindow: replace old tool results with stubs
31
+ export const COMPACT_SUMMARY_AT = 0.78; // of contextWindow: fold history into a recorded summary
32
+ export const COMPACT_KEEP_RESULTS = 3; // most recent tool results always kept verbatim
33
+ export const COMPACT_KEEP_TAIL = 6; // minimum recent messages kept verbatim through a summary fold
34
+ export const COMPACT_MIN_ELIDE_CHARS = 500; // tool results smaller than this are never elided
35
+ export const COMPACT_SUMMARY_MAX_TOKENS = 2048;
36
+ const ELIDE_MARK = "[elided:";
37
+ export const SUMMARIZE_SYSTEM = "You compress an agent conversation so the agent can continue it in less space. " +
38
+ "Write a summary that preserves, in this order: (1) the original task or request, quoted; " +
39
+ "(2) every user message so far, enumerated verbatim; (3) decisions made and constraints discovered; " +
40
+ "(4) facts, numbers, names, and URLs learned from tools that are still needed; " +
41
+ "(5) current state of the work; (6) the immediate next step. " +
42
+ "Be dense and specific. Output only the summary text.";
43
+ // Events the loop itself emits in execution order; replay walks these.
44
+ const WALK_TYPES = new Set([
45
+ "LlmCallStarted", "LlmCallCompleted",
46
+ "ToolCallStarted", "ToolCallCompleted",
47
+ "ApprovalRequested", "TaskCompleted",
48
+ "InputRequested", "UserMessage",
49
+ "ContextCompacted",
50
+ ]);
51
+ /** Layered onto the system prompt in session mode; constant, so replay digests stay stable. */
52
+ export const SESSION_PREAMBLE = "\n\nYou are in an interactive session with a user. Answer their current message directly; " +
53
+ "ask a clarifying question when the request is ambiguous. Keep responses conversational and " +
54
+ "sized to the question — the user can always ask for more.";
55
+ export async function runTaskLoop(args) {
56
+ return withSpan("toren.task", { "toren.run_id": args.runId, "toren.task_id": args.taskId }, () => runTaskLoopImpl(args));
57
+ }
58
+ async function runTaskLoopImpl(args) {
59
+ const { store, provider, runId, taskId, agent } = args;
60
+ const streamId = `task:${taskId}`;
61
+ const raw = await store.read(runId, streamId);
62
+ let head = raw.at(-1)?.seq ?? 0;
63
+ const eff = effectiveEvents(raw);
64
+ const terminalFailure = eff.find((e) => e.type === "TaskFailed" && !e.payload.willRetry);
65
+ if (terminalFailure)
66
+ return { status: "failed", error: String(terminalFailure.payload.error ?? "") };
67
+ const resolutions = new Map();
68
+ for (const e of eff) {
69
+ if (e.type === "ApprovalResolved") {
70
+ resolutions.set(String(e.payload.stepId), {
71
+ granted: Boolean(e.payload.granted),
72
+ by: e.payload.by,
73
+ comment: e.payload.comment,
74
+ });
75
+ }
76
+ }
77
+ const walk = eff.filter((e) => WALK_TYPES.has(e.type));
78
+ let ptr = 0;
79
+ let invalidated = false;
80
+ const peek = () => (invalidated || ptr >= walk.length ? undefined : walk[ptr]);
81
+ async function append(events) {
82
+ const r = await store.append(runId, streamId, head, events);
83
+ if (!r.ok)
84
+ throw new TaskLeaseLostError(`stream advanced concurrently (expected ${head}, actual ${r.actualSeq})`);
85
+ head = r.lastSeq;
86
+ }
87
+ const recentInvalidations = raw.filter((e) => e.type === "StreamInvalidated" && Date.now() - e.recordedAt.getTime() < INVALIDATION_STORM_WINDOW_MS).length;
88
+ async function invalidateFrom(fromSeq, reason) {
89
+ if (recentInvalidations >= INVALIDATION_STORM_LIMIT) {
90
+ throw new InvalidationStormError(`invalidation storm: ${recentInvalidations} invalidations on ${streamId} in the last 5m — deferring instead of re-paying (likely mixed worker versions mid-deploy); latest cause: ${reason}`);
91
+ }
92
+ await append([ev("StreamInvalidated", { fromSeq, reason })]);
93
+ invalidated = true;
94
+ }
95
+ const attempt = raw.filter((e) => e.type === "TaskStarted").length + 1;
96
+ await append([ev("TaskStarted", { attempt })]);
97
+ const messages = [{ role: "user", content: [{ type: "text", text: args.input }] }];
98
+ const specs = toolSpecs(agent.tools);
99
+ const system = args.sessionMode ? agent.system + SESSION_PREAMBLE : agent.system;
100
+ let steps = 0;
101
+ // ---- context pressure: exact usage from the previous model call plus a
102
+ // chars/3 bound on what we appended since. Deterministic on replay because
103
+ // usage rides in LlmCallCompleted and appends are reconstructed identically.
104
+ const contextWindow = agent.contextWindow ?? defaultContextWindow(agent.model);
105
+ let lastUsage;
106
+ let pendingChars = 0;
107
+ let elidedSavingsTokens = 0;
108
+ const pressure = () => lastUsage ? lastUsage.inputTokens + lastUsage.outputTokens + Math.ceil(pendingChars / 3) - elidedSavingsTokens : 0;
109
+ /** The record/replay dance for one model call; identical semantics for the main loop and summarization. */
110
+ async function recordedLlmCall(request) {
111
+ const digest = canonicalDigest(request);
112
+ let next = peek();
113
+ if (next?.type === "ContextCompacted") {
114
+ // A recorded compaction the live code no longer performs here: stale suffix.
115
+ await invalidateFrom(next.seq, "compaction decision changed (code or thresholds)");
116
+ next = peek();
117
+ }
118
+ if (next?.type === "LlmCallStarted" && next.payload.requestDigest !== digest) {
119
+ await invalidateFrom(next.seq, "request digest mismatch (prompt or code changed)");
120
+ next = peek();
121
+ }
122
+ let response;
123
+ if (next?.type === "LlmCallStarted") {
124
+ const completed = walk[ptr + 1];
125
+ if (completed?.type === "LlmCallCompleted" && completed.payload.stepId === next.payload.stepId) {
126
+ response = completed.payload.response; // replayed — zero tokens spent
127
+ ptr += 2;
128
+ }
129
+ else {
130
+ // Crash window: call was issued but the response never landed. Re-issue (at-least-once).
131
+ ptr += 1;
132
+ response = await withSpan("toren.llm", { "gen_ai.request.model": request.model }, () => provider.complete(request));
133
+ await append([ev("LlmCallCompleted", { stepId: next.payload.stepId, response, usage: response.usage })]);
134
+ }
135
+ }
136
+ else {
137
+ const stepId = `s${head + 1}`;
138
+ await append([ev("LlmCallStarted", { stepId, requestDigest: digest, model: request.model })]);
139
+ response = await withSpan("toren.llm", { "gen_ai.request.model": request.model }, () => provider.complete(request));
140
+ await append([ev("LlmCallCompleted", { stepId, response, usage: response.usage })]);
141
+ }
142
+ if (response.usage) {
143
+ lastUsage = response.usage;
144
+ pendingChars = 0;
145
+ elidedSavingsTokens = 0;
146
+ }
147
+ return response;
148
+ }
149
+ /** Old, large tool results not among the last COMPACT_KEEP_RESULTS and not already stubbed. */
150
+ function elidableTargets() {
151
+ const hits = [];
152
+ for (const m of messages) {
153
+ if (m.role !== "user")
154
+ continue;
155
+ for (const b of m.content) {
156
+ if (b.type === "toolResult" && typeof b.content === "string"
157
+ && b.content.length >= COMPACT_MIN_ELIDE_CHARS && !b.content.startsWith(ELIDE_MARK)) {
158
+ hits.push({ id: b.toolUseId, chars: b.content.length });
159
+ }
160
+ }
161
+ }
162
+ return hits.slice(0, Math.max(0, hits.length - COMPACT_KEEP_RESULTS)).map((h) => h.id);
163
+ }
164
+ function toolNameFor(toolUseId) {
165
+ for (const m of messages) {
166
+ if (m.role !== "assistant")
167
+ continue;
168
+ for (const b of m.content)
169
+ if (b.type === "toolUse" && b.id === toolUseId)
170
+ return b.name;
171
+ }
172
+ return "the tool";
173
+ }
174
+ function applyElide(toolUseIds) {
175
+ const ids = new Set(Array.isArray(toolUseIds) ? toolUseIds.map(String) : []);
176
+ for (const m of messages) {
177
+ if (m.role !== "user")
178
+ continue;
179
+ m.content = m.content.map((b) => {
180
+ if (b.type !== "toolResult" || !ids.has(b.toolUseId) || typeof b.content !== "string")
181
+ return b;
182
+ const name = toolNameFor(b.toolUseId);
183
+ elidedSavingsTokens += Math.floor(b.content.length / 3);
184
+ return {
185
+ ...b,
186
+ content: `${ELIDE_MARK} earlier ${name} result removed to save context. The full output is preserved in the run's event log; call ${name} again if you need it.]`,
187
+ };
188
+ });
189
+ }
190
+ }
191
+ /** Largest boundary landing on an assistant message, keeping at least COMPACT_KEEP_TAIL recent messages. */
192
+ function summaryBoundary() {
193
+ for (let b = messages.length - COMPACT_KEEP_TAIL; b >= 1; b--) {
194
+ if (messages[b].role === "assistant")
195
+ return b;
196
+ }
197
+ return 0;
198
+ }
199
+ function applySummary(payload) {
200
+ const keepFrom = Number(payload.keepFrom);
201
+ const summary = String(payload.summary ?? "");
202
+ messages.splice(0, keepFrom, {
203
+ role: "user",
204
+ content: [{
205
+ type: "text",
206
+ text: `[The earlier conversation was compacted to save context. Summary of everything before this point:]\n\n${summary}\n\n[Continue the task from here. The recent messages below are verbatim.]`,
207
+ }],
208
+ });
209
+ }
210
+ /**
211
+ * Compaction pass, run before each model call. Both tiers are recorded events:
212
+ * replay applies the recorded payload by value, so the fold is a pure function
213
+ * of the log and survives prompt, threshold, and code changes.
214
+ */
215
+ async function maybeCompact() {
216
+ if (!contextWindow || !lastUsage || invalidated)
217
+ return;
218
+ if (pressure() >= COMPACT_ELIDE_AT * contextWindow) {
219
+ const targets = elidableTargets();
220
+ if (targets.length > 0) {
221
+ const next = peek();
222
+ if (next?.type === "ContextCompacted" && next.payload.kind === "elide") {
223
+ ptr += 1;
224
+ applyElide(next.payload.toolUseIds);
225
+ }
226
+ else {
227
+ // A differing recorded suffix (older code compacted differently) is
228
+ // voided first; the append then lands after the StreamInvalidated cut.
229
+ if (next)
230
+ await invalidateFrom(next.seq, "compaction decision changed (code or thresholds)");
231
+ await append([ev("ContextCompacted", { kind: "elide", toolUseIds: targets })]);
232
+ applyElide(targets);
233
+ }
234
+ }
235
+ }
236
+ if (pressure() >= COMPACT_SUMMARY_AT * contextWindow) {
237
+ const keepFrom = summaryBoundary();
238
+ if (keepFrom <= 1)
239
+ return; // nothing worth folding
240
+ const sumRequest = {
241
+ model: agent.model,
242
+ system: SUMMARIZE_SYSTEM,
243
+ messages: messages.slice(0, keepFrom),
244
+ tools: specs,
245
+ maxTokens: COMPACT_SUMMARY_MAX_TOKENS,
246
+ };
247
+ const sumResponse = await recordedLlmCall(sumRequest);
248
+ const summary = sumResponse.content
249
+ .filter((b) => b.type === "text")
250
+ .map((b) => b.text).join("\n");
251
+ const next = peek();
252
+ if (next?.type === "ContextCompacted" && next.payload.kind === "summary") {
253
+ ptr += 1;
254
+ applySummary(next.payload);
255
+ }
256
+ else {
257
+ if (next)
258
+ await invalidateFrom(next.seq, "compaction decision changed (code or thresholds)");
259
+ await append([ev("ContextCompacted", { kind: "summary", keepFrom, summary })]);
260
+ applySummary({ keepFrom, summary });
261
+ }
262
+ // The summary replaces the history the trigger measured; usage resets on the next call.
263
+ lastUsage = undefined;
264
+ }
265
+ }
266
+ async function runHandlerAndComplete(def, tu, stepId) {
267
+ let result;
268
+ let isError = false;
269
+ try {
270
+ const parsed = def.input.parse(tu.input);
271
+ result = await withSpan("toren.tool", { "toren.tool.name": def.name, "toren.tool.effects": def.effects }, () => def.handler(parsed, { runId, taskId, toolUseId: tu.id, env: agent.env ?? {}, files: args.files, sandbox: args.sandbox, processes: args.processes }));
272
+ }
273
+ catch (e) {
274
+ result = `tool error: ${e instanceof Error ? e.message : String(e)}`;
275
+ isError = true;
276
+ }
277
+ await append([ev("ToolCallCompleted", { stepId, toolUseId: tu.id, result, isError })]);
278
+ return { type: "toolResult", toolUseId: tu.id, content: result, ...(isError ? { isError } : {}) };
279
+ }
280
+ // Replays a recorded execution of this toolUse if the walk holds one; null = nothing recorded.
281
+ async function replayRecordedTool(def, tu) {
282
+ const next = peek();
283
+ if (next?.type !== "ToolCallStarted" || next.payload.toolUseId !== tu.id)
284
+ return null;
285
+ const completed = walk[ptr + 1];
286
+ if (completed?.type === "ToolCallCompleted" && completed.payload.toolUseId === tu.id) {
287
+ ptr += 2;
288
+ const isError = Boolean(completed.payload.isError);
289
+ return { type: "toolResult", toolUseId: tu.id, content: String(completed.payload.result ?? ""), ...(isError ? { isError } : {}) };
290
+ }
291
+ // Crash window: started, never completed. Keyed tools re-run under the same
292
+ // idempotency key (effectively-once downstream); unkeyed tools are documented at-least-once.
293
+ ptr += 1;
294
+ return runHandlerAndComplete(def, tu, String(next.payload.stepId));
295
+ }
296
+ async function executeToolLive(def, tu) {
297
+ const recorded = await replayRecordedTool(def, tu);
298
+ if (recorded)
299
+ return recorded;
300
+ const stepId = `s${head + 1}`;
301
+ const idempotencyKey = canonicalDigest({ runId, taskId, stepId, tool: def.name, args: tu.input });
302
+ await append([ev("ToolCallStarted", { stepId, toolUseId: tu.id, tool: def.name, args: tu.input, idempotencyKey, effects: def.effects })]);
303
+ return runHandlerAndComplete(def, tu, stepId);
304
+ }
305
+ async function execTool(tu) {
306
+ const def = agent.tools.find((t) => t.name === tu.name);
307
+ if (!def)
308
+ return { type: "toolResult", toolUseId: tu.id, content: `unknown tool: ${tu.name}`, isError: true };
309
+ const next = peek();
310
+ if (next?.type === "ApprovalRequested" && next.payload.toolUseId === tu.id) {
311
+ ptr += 1;
312
+ const res = resolutions.get(String(next.payload.stepId));
313
+ if (!res)
314
+ return "PARKED"; // still parked; the recorded request stands
315
+ if (!res.granted) {
316
+ return {
317
+ type: "toolResult", toolUseId: tu.id, isError: true,
318
+ content: `denied by ${res.by ?? "operator"}${res.comment ? `: ${res.comment}` : ""}`,
319
+ };
320
+ }
321
+ return executeToolLive(def, tu);
322
+ }
323
+ const recorded = await replayRecordedTool(def, tu);
324
+ if (recorded)
325
+ return recorded;
326
+ if (needsApproval(def, tu.input)) {
327
+ const stepId = `s${head + 1}`;
328
+ await append([ev("ApprovalRequested", { stepId, toolUseId: tu.id, tool: tu.name, args: tu.input })]);
329
+ return "PARKED";
330
+ }
331
+ return executeToolLive(def, tu);
332
+ }
333
+ while (true) {
334
+ if (++steps > agent.maxSteps) {
335
+ const error = "maxSteps exceeded";
336
+ await append([ev("TaskFailed", { error, willRetry: false })]);
337
+ return { status: "failed", error };
338
+ }
339
+ await maybeCompact();
340
+ const request = { model: agent.model, system, messages: [...messages], tools: specs, maxTokens: agent.maxTokens };
341
+ const response = await recordedLlmCall(request);
342
+ messages.push({ role: "assistant", content: response.content });
343
+ if (response.stopReason === "toolUse") {
344
+ const toolUses = response.content.filter((b) => b.type === "toolUse");
345
+ const results = [];
346
+ for (const tu of toolUses) {
347
+ const r = await execTool(tu);
348
+ if (r === "PARKED")
349
+ return { status: "waitingApproval" };
350
+ results.push(r);
351
+ }
352
+ messages.push({ role: "user", content: results });
353
+ pendingChars += JSON.stringify(results).length;
354
+ continue;
355
+ }
356
+ if (response.stopReason === "refusal") {
357
+ const error = "model refused the request";
358
+ await append([ev("TaskFailed", { error, willRetry: false })]);
359
+ return { status: "failed", error };
360
+ }
361
+ // endTurn / maxTokens → final output
362
+ const text = response.content
363
+ .filter((b) => b.type === "text")
364
+ .map((b) => b.text).join("\n");
365
+ if (args.sessionMode) {
366
+ // Turn boundary: park awaiting the user (or consume their recorded reply and go again).
367
+ const recInput = peek();
368
+ if (recInput?.type === "InputRequested") {
369
+ ptr += 1;
370
+ }
371
+ else {
372
+ await append([ev("InputRequested", { text })]);
373
+ return { status: "awaitingInput" };
374
+ }
375
+ const userMsg = peek();
376
+ if (userMsg?.type !== "UserMessage")
377
+ return { status: "awaitingInput" };
378
+ ptr += 1;
379
+ if (userMsg.payload.close) {
380
+ const recordedClose = peek();
381
+ if (recordedClose?.type === "TaskCompleted") {
382
+ ptr += 1;
383
+ return { status: "completed", output: String(recordedClose.payload.result ?? "") };
384
+ }
385
+ await append([ev("TaskCompleted", { result: text })]);
386
+ return { status: "completed", output: text };
387
+ }
388
+ messages.push({ role: "user", content: [{ type: "text", text: String(userMsg.payload.text ?? "") }] });
389
+ pendingChars += String(userMsg.payload.text ?? "").length;
390
+ steps = 0; // each user turn gets a fresh step budget
391
+ continue;
392
+ }
393
+ if (agent.outputSchema) {
394
+ try {
395
+ agent.outputSchema.parse(JSON.parse(text));
396
+ }
397
+ catch (e) {
398
+ messages.push({
399
+ role: "user",
400
+ content: [{ type: "text", text: `Your final answer failed output validation: ${e instanceof Error ? e.message : String(e)}. Reply with a corrected final answer only.` }],
401
+ });
402
+ continue;
403
+ }
404
+ }
405
+ const recordedDone = peek();
406
+ if (recordedDone?.type === "TaskCompleted") {
407
+ ptr += 1;
408
+ return { status: "completed", output: String(recordedDone.payload.result ?? "") };
409
+ }
410
+ await append([ev("TaskCompleted", { result: text })]);
411
+ return { status: "completed", output: text };
412
+ }
413
+ }
@@ -0,0 +1,4 @@
1
+ import type pg from "pg";
2
+ export declare function migrateControl(c: pg.PoolClient): Promise<void>;
3
+ export declare function agentSchemaName(agent: string): string;
4
+ export declare function provisionAgent(c: pg.PoolClient, agent: string): Promise<string>;
@@ -0,0 +1,167 @@
1
+ const CONTROL_SQL = `
2
+ CREATE SCHEMA IF NOT EXISTS toren_control;
3
+ CREATE TABLE IF NOT EXISTS toren_control.agents (
4
+ name text PRIMARY KEY,
5
+ schema_name text NOT NULL UNIQUE,
6
+ created_at timestamptz NOT NULL DEFAULT now()
7
+ );
8
+ CREATE TABLE IF NOT EXISTS toren_control.queue_messages (
9
+ id bigserial PRIMARY KEY,
10
+ queue text NOT NULL,
11
+ payload jsonb NOT NULL,
12
+ dedupe_key text,
13
+ visible_at timestamptz NOT NULL DEFAULT now(),
14
+ locked_until timestamptz,
15
+ attempts int NOT NULL DEFAULT 0,
16
+ max_attempts int NOT NULL DEFAULT 5,
17
+ created_at timestamptz NOT NULL DEFAULT now()
18
+ );
19
+ CREATE INDEX IF NOT EXISTS queue_visible_idx ON toren_control.queue_messages (queue, visible_at) WHERE locked_until IS NULL;
20
+ CREATE TABLE IF NOT EXISTS toren_control.dead_letters (
21
+ id bigint PRIMARY KEY,
22
+ queue text NOT NULL,
23
+ payload jsonb NOT NULL,
24
+ attempts int NOT NULL,
25
+ failed_at timestamptz NOT NULL DEFAULT now()
26
+ );
27
+ CREATE TABLE IF NOT EXISTS toren_control.schedules (
28
+ id uuid PRIMARY KEY,
29
+ agent text NOT NULL,
30
+ name text NOT NULL,
31
+ cron text NOT NULL,
32
+ tz text NOT NULL DEFAULT 'UTC',
33
+ input text NOT NULL,
34
+ process text NOT NULL DEFAULT 'main',
35
+ enabled boolean NOT NULL DEFAULT true,
36
+ next_fire_at timestamptz NOT NULL,
37
+ last_fired_at timestamptz,
38
+ created_at timestamptz NOT NULL DEFAULT now()
39
+ );
40
+ ALTER TABLE toren_control.schedules ADD COLUMN IF NOT EXISTS process text NOT NULL DEFAULT 'main';
41
+ CREATE INDEX IF NOT EXISTS schedules_due_idx ON toren_control.schedules (next_fire_at) WHERE enabled;
42
+ CREATE TABLE IF NOT EXISTS toren_control.schedule_fires (
43
+ schedule_id uuid NOT NULL,
44
+ scheduled_for timestamptz NOT NULL,
45
+ run_id uuid NOT NULL,
46
+ agent text NOT NULL,
47
+ input text NOT NULL,
48
+ process text NOT NULL DEFAULT 'main',
49
+ fired_at timestamptz NOT NULL DEFAULT now(),
50
+ settled boolean NOT NULL DEFAULT false,
51
+ PRIMARY KEY (schedule_id, scheduled_for)
52
+ );
53
+ ALTER TABLE toren_control.schedule_fires ADD COLUMN IF NOT EXISTS process text NOT NULL DEFAULT 'main';
54
+ CREATE INDEX IF NOT EXISTS schedule_fires_open_idx ON toren_control.schedule_fires (agent) WHERE NOT settled;
55
+ CREATE TABLE IF NOT EXISTS toren_control.api_keys (
56
+ id uuid PRIMARY KEY,
57
+ name text NOT NULL,
58
+ key_hash text NOT NULL UNIQUE,
59
+ prefix text NOT NULL,
60
+ created_at timestamptz NOT NULL DEFAULT now(),
61
+ revoked_at timestamptz,
62
+ last_used_at timestamptz
63
+ );
64
+ CREATE TABLE IF NOT EXISTS toren_control.telegram_users (
65
+ user_id bigint PRIMARY KEY,
66
+ paired_at timestamptz NOT NULL DEFAULT now(),
67
+ via_code text
68
+ );
69
+ CREATE TABLE IF NOT EXISTS toren_control.telegram_invites (
70
+ code text PRIMARY KEY,
71
+ created_at timestamptz NOT NULL DEFAULT now(),
72
+ used_by bigint
73
+ );
74
+ CREATE TABLE IF NOT EXISTS toren_control.telegram_bindings (
75
+ chat_id bigint PRIMARY KEY,
76
+ agent text NOT NULL,
77
+ run_id uuid,
78
+ last_delivered_seq int NOT NULL DEFAULT 0,
79
+ updated_at timestamptz NOT NULL DEFAULT now()
80
+ );
81
+ CREATE TABLE IF NOT EXISTS toren_control.telegram_state (
82
+ id int PRIMARY KEY DEFAULT 1,
83
+ last_update_id bigint NOT NULL DEFAULT 0
84
+ );
85
+ CREATE TABLE IF NOT EXISTS toren_control.files (
86
+ id text PRIMARY KEY,
87
+ name text NOT NULL,
88
+ media_type text NOT NULL,
89
+ bytes int NOT NULL,
90
+ pages jsonb NOT NULL,
91
+ data bytea,
92
+ created_at timestamptz NOT NULL DEFAULT now()
93
+ );
94
+ CREATE TABLE IF NOT EXISTS toren_control.run_watchers (
95
+ child_run_id uuid PRIMARY KEY,
96
+ parent_run_id uuid NOT NULL,
97
+ agent text NOT NULL,
98
+ process text NOT NULL,
99
+ created_at timestamptz NOT NULL DEFAULT now(),
100
+ settled boolean NOT NULL DEFAULT false
101
+ );
102
+ CREATE INDEX IF NOT EXISTS run_watchers_open_idx ON toren_control.run_watchers (agent) WHERE NOT settled;
103
+ CREATE TABLE IF NOT EXISTS toren_control.sandboxes (
104
+ run_id uuid PRIMARY KEY,
105
+ provider text NOT NULL,
106
+ sandbox_id text NOT NULL,
107
+ created_at timestamptz NOT NULL DEFAULT now(),
108
+ last_used_at timestamptz NOT NULL DEFAULT now()
109
+ );
110
+ `;
111
+ const AGENT_TABLES_SQL = (s) => `
112
+ CREATE SCHEMA IF NOT EXISTS ${s};
113
+ CREATE TABLE IF NOT EXISTS ${s}.runs (
114
+ run_id uuid PRIMARY KEY,
115
+ agent text NOT NULL,
116
+ status text NOT NULL DEFAULT 'created',
117
+ input jsonb, output jsonb, error jsonb,
118
+ code_hash text, trace_context jsonb,
119
+ mode text NOT NULL DEFAULT 'task',
120
+ process text NOT NULL DEFAULT 'main',
121
+ created_at timestamptz NOT NULL DEFAULT now(),
122
+ updated_at timestamptz NOT NULL DEFAULT now()
123
+ );
124
+ ALTER TABLE ${s}.runs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'task';
125
+ ALTER TABLE ${s}.runs ADD COLUMN IF NOT EXISTS process text NOT NULL DEFAULT 'main';
126
+ CREATE TABLE IF NOT EXISTS ${s}.streams (
127
+ run_id uuid NOT NULL, stream_id text NOT NULL,
128
+ head_seq bigint NOT NULL DEFAULT 0,
129
+ PRIMARY KEY (run_id, stream_id)
130
+ );
131
+ CREATE TABLE IF NOT EXISTS ${s}.events (
132
+ run_id uuid NOT NULL, stream_id text NOT NULL, seq bigint NOT NULL,
133
+ type text NOT NULL, payload jsonb NOT NULL,
134
+ recorded_at timestamptz NOT NULL DEFAULT now(),
135
+ PRIMARY KEY (run_id, stream_id, seq)
136
+ );
137
+ CREATE TABLE IF NOT EXISTS ${s}.leases (
138
+ run_id uuid NOT NULL, stream_id text NOT NULL,
139
+ owner text NOT NULL, epoch bigint NOT NULL DEFAULT 1,
140
+ expires_at timestamptz NOT NULL,
141
+ PRIMARY KEY (run_id, stream_id)
142
+ );
143
+ CREATE TABLE IF NOT EXISTS ${s}.blobs (
144
+ run_id uuid NOT NULL, key text NOT NULL, data bytea NOT NULL,
145
+ created_at timestamptz NOT NULL DEFAULT now(),
146
+ PRIMARY KEY (run_id, key)
147
+ );
148
+ `;
149
+ const AGENT_NAME_RE = /^[a-z][a-z0-9_]{0,40}$/;
150
+ // Concurrent booters (parallel workers, parallel test files) race CREATE IF NOT EXISTS;
151
+ // a transaction-scoped advisory lock serializes DDL. 727001/727002 are arbitrary app-unique keys.
152
+ export async function migrateControl(c) {
153
+ await c.query(`SELECT pg_advisory_xact_lock(727001)`);
154
+ await c.query(CONTROL_SQL);
155
+ }
156
+ export function agentSchemaName(agent) {
157
+ if (!AGENT_NAME_RE.test(agent))
158
+ throw new Error(`invalid agent name: ${agent}`);
159
+ return `agent_${agent}`;
160
+ }
161
+ export async function provisionAgent(c, agent) {
162
+ const schema = agentSchemaName(agent);
163
+ await c.query(`SELECT pg_advisory_xact_lock(727002)`);
164
+ await c.query(AGENT_TABLES_SQL(schema));
165
+ await c.query(`INSERT INTO toren_control.agents (name, schema_name) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [agent, schema]);
166
+ return schema;
167
+ }
@@ -0,0 +1,42 @@
1
+ export interface ToolSpec {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: Record<string, unknown>;
5
+ }
6
+ export type ContentBlock = {
7
+ type: "text";
8
+ text: string;
9
+ } | {
10
+ type: "toolUse";
11
+ id: string;
12
+ name: string;
13
+ input: unknown;
14
+ } | {
15
+ type: "toolResult";
16
+ toolUseId: string;
17
+ content: string;
18
+ isError?: boolean;
19
+ };
20
+ export interface ChatMessage {
21
+ role: "user" | "assistant";
22
+ content: ContentBlock[];
23
+ }
24
+ export interface ModelRequest {
25
+ model: string;
26
+ system: string;
27
+ messages: ChatMessage[];
28
+ tools: ToolSpec[];
29
+ maxTokens: number;
30
+ }
31
+ export type StopReason = "endTurn" | "toolUse" | "maxTokens" | "refusal";
32
+ export interface ModelResponse {
33
+ content: ContentBlock[];
34
+ stopReason: StopReason;
35
+ usage: {
36
+ inputTokens: number;
37
+ outputTokens: number;
38
+ };
39
+ }
40
+ export interface ModelProvider {
41
+ complete(req: ModelRequest): Promise<ModelResponse>;
42
+ }
package/dist/model.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import type { PgStateStore } from "./store.js";
2
+ import type { QueueAdapter } from "./queue.js";
3
+ import type { PgLeases } from "./leases.js";
4
+ import type { ModelProvider } from "./model.js";
5
+ import type { AgentSpec } from "./loop.js";
6
+ import { type WorkflowFn } from "./workflow.js";
7
+ export interface TickDeps {
8
+ store: PgStateStore;
9
+ queue: QueueAdapter;
10
+ leases: PgLeases;
11
+ provider: ModelProvider;
12
+ agents: Record<string, AgentSpec>;
13
+ /** Keyed by process name — an agent's named workflows ("main" is the single/default one). */
14
+ workflows: Record<string, WorkflowFn>;
15
+ /** Per-run sandbox provider; enables the bash builtin. */
16
+ sandbox?: import("./tools.js").SandboxProvider;
17
+ /** Deployment file store; enables the read_file builtin. */
18
+ files?: import("./files.js").PgFiles;
19
+ /** Background named-process runs; enables the run_process/check_run builtins. */
20
+ processes?: import("./tools.js").ProcessesCtx;
21
+ }
22
+ export type TickResult = "leased" | "terminal" | "blocked" | "completed" | "failed";
23
+ export declare function startRun(deps: TickDeps, req: {
24
+ agent: string;
25
+ input: string;
26
+ process?: string;
27
+ runId?: string;
28
+ mode?: "task" | "session";
29
+ }): Promise<string>;
30
+ export declare function tick(deps: TickDeps, runId: string): Promise<TickResult>;
31
+ /** Locates a planned task's spec by scanning the run stream (workers use this). */
32
+ export declare function findTaskSpec(store: PgStateStore, runId: string, taskId: string): Promise<{
33
+ agentRef: string;
34
+ input: string;
35
+ } | null>;