@toren-run/core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -25,3 +25,5 @@ export * from "./worker.js";
25
25
  export * from "./guardians.js";
26
26
  export * from "./conversations.js";
27
27
  export * from "./spawn.js";
28
+ export * from "./usage.js";
29
+ export * from "./tail.js";
package/dist/index.js CHANGED
@@ -25,3 +25,5 @@ export * from "./worker.js";
25
25
  export * from "./guardians.js";
26
26
  export * from "./conversations.js";
27
27
  export * from "./spawn.js";
28
+ export * from "./usage.js";
29
+ export * from "./tail.js";
package/dist/loop.d.ts CHANGED
@@ -22,6 +22,10 @@ export interface AgentSpec {
22
22
  tools: ToolDefAny[];
23
23
  maxTokens: number;
24
24
  maxSteps: number;
25
+ /** Passed to reasoning models (agent.yaml `reasoning_effort:`); absent leaves request digests untouched. */
26
+ reasoningEffort?: string;
27
+ /** Opt-in poison-pill (agent.yaml `limits.maxAttemptsPerTask`): terminally fail a task after this many attempts instead of retrying forever. */
28
+ maxTaskAttempts?: number;
25
29
  outputSchema?: z.ZodTypeAny;
26
30
  /** Declared env values (from agent.yaml `env:`) passed to tool handlers as ctx.env. */
27
31
  env?: Record<string, string>;
package/dist/loop.js CHANGED
@@ -93,6 +93,11 @@ async function runTaskLoopImpl(args) {
93
93
  invalidated = true;
94
94
  }
95
95
  const attempt = raw.filter((e) => e.type === "TaskStarted").length + 1;
96
+ if (agent.maxTaskAttempts && attempt > agent.maxTaskAttempts) {
97
+ const error = `gave up after ${agent.maxTaskAttempts} attempts (limits.maxAttemptsPerTask); the run's recorded error holds the last failure reason`;
98
+ await append([ev("TaskFailed", { error, willRetry: false })]);
99
+ return { status: "failed", error };
100
+ }
96
101
  await append([ev("TaskStarted", { attempt })]);
97
102
  const messages = [{ role: "user", content: [{ type: "text", text: args.input }] }];
98
103
  const specs = toolSpecs(agent.tools);
@@ -129,14 +134,24 @@ async function runTaskLoopImpl(args) {
129
134
  else {
130
135
  // Crash window: call was issued but the response never landed. Re-issue (at-least-once).
131
136
  ptr += 1;
132
- response = await withSpan("toren.llm", { "gen_ai.request.model": request.model }, () => provider.complete(request));
137
+ response = await withSpan("toren.llm", { "gen_ai.request.model": request.model }, async (span) => {
138
+ const r = await provider.complete(request);
139
+ if (r.usage)
140
+ span.setAttributes({ "gen_ai.usage.input_tokens": r.usage.inputTokens, "gen_ai.usage.output_tokens": r.usage.outputTokens });
141
+ return r;
142
+ });
133
143
  await append([ev("LlmCallCompleted", { stepId: next.payload.stepId, response, usage: response.usage })]);
134
144
  }
135
145
  }
136
146
  else {
137
147
  const stepId = `s${head + 1}`;
138
148
  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));
149
+ response = await withSpan("toren.llm", { "gen_ai.request.model": request.model }, async (span) => {
150
+ const r = await provider.complete(request);
151
+ if (r.usage)
152
+ span.setAttributes({ "gen_ai.usage.input_tokens": r.usage.inputTokens, "gen_ai.usage.output_tokens": r.usage.outputTokens });
153
+ return r;
154
+ });
140
155
  await append([ev("LlmCallCompleted", { stepId, response, usage: response.usage })]);
141
156
  }
142
157
  if (response.usage) {
@@ -337,7 +352,7 @@ async function runTaskLoopImpl(args) {
337
352
  return { status: "failed", error };
338
353
  }
339
354
  await maybeCompact();
340
- const request = { model: agent.model, system, messages: [...messages], tools: specs, maxTokens: agent.maxTokens };
355
+ const request = { model: agent.model, system, messages: [...messages], tools: specs, maxTokens: agent.maxTokens, ...(agent.reasoningEffort ? { reasoningEffort: agent.reasoningEffort } : {}) };
341
356
  const response = await recordedLlmCall(request);
342
357
  messages.push({ role: "assistant", content: response.content });
343
358
  if (response.stopReason === "toolUse") {
package/dist/model.d.ts CHANGED
@@ -27,6 +27,8 @@ export interface ModelRequest {
27
27
  messages: ChatMessage[];
28
28
  tools: ToolSpec[];
29
29
  maxTokens: number;
30
+ /** OpenAI reasoning models: "none" | "low" | "medium" | "high". Absent keeps old request digests byte-identical. */
31
+ reasoningEffort?: string;
30
32
  }
31
33
  export type StopReason = "endTurn" | "toolUse" | "maxTokens" | "refusal";
32
34
  export interface ModelResponse {
@@ -27,6 +27,13 @@ export declare function startRun(deps: TickDeps, req: {
27
27
  runId?: string;
28
28
  mode?: "task" | "session";
29
29
  }): Promise<string>;
30
+ /**
31
+ * Retire a run from outside: appends RunCancelled and marks the row, so every
32
+ * queued hint for it becomes a no-op and retries stop. The escape hatch for a
33
+ * run stuck retrying a permanently broken dependency. Returns false for an
34
+ * unknown run; true if the run is (now) cancelled.
35
+ */
36
+ export declare function cancelRun(deps: TickDeps, runId: string, reason?: string): Promise<boolean>;
30
37
  export declare function tick(deps: TickDeps, runId: string): Promise<TickResult>;
31
38
  /** Locates a planned task's spec by scanning the run stream (workers use this). */
32
39
  export declare function findTaskSpec(store: PgStateStore, runId: string, taskId: string): Promise<{
@@ -20,6 +20,31 @@ export async function startRun(deps, req) {
20
20
  await deps.queue.send("orchestrator", { kind: "tick", runId, agent: req.agent, dedupeKey: `start-${runId}` });
21
21
  return runId;
22
22
  }
23
+ /**
24
+ * Retire a run from outside: appends RunCancelled and marks the row, so every
25
+ * queued hint for it becomes a no-op and retries stop. The escape hatch for a
26
+ * run stuck retrying a permanently broken dependency. Returns false for an
27
+ * unknown run; true if the run is (now) cancelled.
28
+ */
29
+ export async function cancelRun(deps, runId, reason = "cancelled by operator") {
30
+ for (let i = 0; i < 5; i++) {
31
+ const run = await deps.store.getRun(runId);
32
+ if (!run)
33
+ return false;
34
+ if (run.status === "cancelled")
35
+ return true;
36
+ if (run.status === "completed" || run.status === "failed")
37
+ return false;
38
+ const head = (await deps.store.read(runId, "run")).at(-1)?.seq ?? 0;
39
+ const r = await deps.store.append(runId, "run", head, [ev("RunCancelled", { reason })]);
40
+ if (r.ok) {
41
+ await deps.store.updateRun(runId, { status: "cancelled", error: reason });
42
+ return true;
43
+ }
44
+ // A worker advanced the stream between read and append; look again.
45
+ }
46
+ throw new Error(`could not cancel ${runId}: the run stream kept advancing`);
47
+ }
23
48
  export async function tick(deps, runId) {
24
49
  return withSpan("toren.run.tick", { "toren.run_id": runId }, () => tickImpl(deps, runId));
25
50
  }
@@ -107,14 +132,15 @@ async function tickImpl(deps, runId) {
107
132
  const r = await deps.store.append(runId, "run", session.head, [ev("RunCompleted", { output })]);
108
133
  if (!r.ok)
109
134
  throw new RunLeaseLostError("run stream advanced concurrently");
110
- await deps.store.updateRun(runId, { status: "completed", output });
135
+ await deps.store.updateRun(runId, { status: "completed", output, error: null });
111
136
  await flush();
112
137
  return "completed";
113
138
  }
114
139
  catch (e) {
115
140
  if (e instanceof WorkflowBlocked) {
116
141
  await flush();
117
- await deps.store.updateRun(runId, { status: "running" });
142
+ // Parking cleanly is progress: a transient error recorded by a worker retry is stale now.
143
+ await deps.store.updateRun(runId, { status: "running", error: null });
118
144
  return "blocked";
119
145
  }
120
146
  if (e instanceof RunLeaseLostError)
package/dist/tail.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { PgStateStore } from "./store.js";
2
+ import type { RecordedEvent, StreamId } from "./events.js";
3
+ /**
4
+ * Incremental follow of a run's event streams, for `toren jobs tail` and the
5
+ * SSE endpoint. The cursor is per-stream last-seen seq; callers poll and get
6
+ * only what landed since. Streams are append-only, so this is exact, not
7
+ * best-effort.
8
+ */
9
+ export type TailCursor = Partial<Record<string, number>>;
10
+ export interface TailedEvent extends RecordedEvent {
11
+ streamId: StreamId;
12
+ }
13
+ export declare function followRun(store: PgStateStore, runId: string, cursor: TailCursor): Promise<{
14
+ events: TailedEvent[];
15
+ done: boolean;
16
+ }>;
package/dist/tail.js ADDED
@@ -0,0 +1,27 @@
1
+ const TERMINAL = new Set(["completed", "failed", "cancelled"]);
2
+ export async function followRun(store, runId, cursor) {
3
+ const events = [];
4
+ const pull = async (streamId) => {
5
+ const after = cursor[streamId] ?? 0;
6
+ for (const e of await store.read(runId, streamId)) {
7
+ if (e.seq <= after)
8
+ continue;
9
+ events.push({ ...e, streamId });
10
+ cursor[streamId] = e.seq;
11
+ }
12
+ };
13
+ await pull("run");
14
+ // Task streams come from every wave ever planned; the run stream is truth.
15
+ const taskIds = new Set();
16
+ for (const e of await store.read(runId, "run")) {
17
+ if (e.type !== "WavePlanned")
18
+ continue;
19
+ for (const t of e.payload.tasks)
20
+ taskIds.add(t.taskId);
21
+ }
22
+ for (const taskId of taskIds)
23
+ await pull(`task:${taskId}`);
24
+ events.sort((a, b) => a.recordedAt.getTime() - b.recordedAt.getTime());
25
+ const run = await store.getRun(runId);
26
+ return { events, done: !run || TERMINAL.has(run.status) };
27
+ }
@@ -0,0 +1,29 @@
1
+ import type { PgStateStore } from "./store.js";
2
+ /**
3
+ * Cost roll-up for one run, straight from the event log. Every model call's
4
+ * usage rides in LlmCallCompleted; the raw streams (not the effective view)
5
+ * are the tally, because invalidated calls were still paid for. The number
6
+ * the product exists for: completed calls recorded before a later attempt
7
+ * replayed from the log instead of the provider, so their cost was paid once.
8
+ */
9
+ export interface RunUsage {
10
+ models: Record<string, {
11
+ calls: number;
12
+ inputTokens: number;
13
+ outputTokens: number;
14
+ }>;
15
+ totalCalls: number;
16
+ /** Sum of TaskStarted across all task streams; > tasks means resumes happened. */
17
+ taskAttempts: number;
18
+ /** Completed calls that later attempts replayed from the log instead of re-buying. */
19
+ replayedCalls: number;
20
+ /** Present only when every used model has a known price. USD. */
21
+ estCostUsd?: number;
22
+ replaySavingsUsd?: number;
23
+ }
24
+ /** USD per million tokens. Approximate list prices; override or extend via TOREN_MODEL_PRICES (JSON, same shape). */
25
+ export declare const MODEL_PRICES: Record<string, {
26
+ in: number;
27
+ out: number;
28
+ }>;
29
+ export declare function runUsage(store: PgStateStore, runId: string): Promise<RunUsage>;
package/dist/usage.js ADDED
@@ -0,0 +1,73 @@
1
+ /** USD per million tokens. Approximate list prices; override or extend via TOREN_MODEL_PRICES (JSON, same shape). */
2
+ export const MODEL_PRICES = {
3
+ "openai/gpt-4o": { in: 2.5, out: 10 },
4
+ "openai/gpt-4o-mini": { in: 0.15, out: 0.6 },
5
+ "mock/echo": { in: 0, out: 0 },
6
+ "mock/slow": { in: 0, out: 0 },
7
+ "mock/m": { in: 0, out: 0 },
8
+ };
9
+ function prices() {
10
+ const env = process.env.TOREN_MODEL_PRICES;
11
+ if (!env)
12
+ return MODEL_PRICES;
13
+ try {
14
+ return { ...MODEL_PRICES, ...JSON.parse(env) };
15
+ }
16
+ catch {
17
+ return MODEL_PRICES;
18
+ }
19
+ }
20
+ export async function runUsage(store, runId) {
21
+ const runEvents = await store.read(runId, "run");
22
+ const taskIds = new Set();
23
+ for (const e of runEvents) {
24
+ if (e.type !== "WavePlanned")
25
+ continue;
26
+ for (const t of e.payload.tasks)
27
+ taskIds.add(t.taskId);
28
+ }
29
+ const table = prices();
30
+ const models = {};
31
+ let totalCalls = 0, taskAttempts = 0, replayedCalls = 0;
32
+ let cost = 0, savings = 0, priceable = true;
33
+ for (const taskId of taskIds) {
34
+ const events = await store.read(runId, `task:${taskId}`);
35
+ const stepModel = new Map();
36
+ let completedSoFar = 0, costSoFar = 0;
37
+ for (const e of events) {
38
+ if (e.type === "TaskStarted") {
39
+ taskAttempts += 1;
40
+ if (Number(e.payload.attempt ?? 1) > 1) {
41
+ replayedCalls += completedSoFar;
42
+ savings += costSoFar;
43
+ }
44
+ }
45
+ else if (e.type === "LlmCallStarted") {
46
+ stepModel.set(String(e.payload.stepId), String(e.payload.model ?? ""));
47
+ }
48
+ else if (e.type === "LlmCallCompleted") {
49
+ const model = stepModel.get(String(e.payload.stepId)) ?? "";
50
+ const usage = (e.payload.usage ?? {});
51
+ const m = (models[model] ??= { calls: 0, inputTokens: 0, outputTokens: 0 });
52
+ m.calls += 1;
53
+ m.inputTokens += usage.inputTokens ?? 0;
54
+ m.outputTokens += usage.outputTokens ?? 0;
55
+ totalCalls += 1;
56
+ completedSoFar += 1;
57
+ const p = table[model];
58
+ if (p) {
59
+ const c = ((usage.inputTokens ?? 0) * p.in + (usage.outputTokens ?? 0) * p.out) / 1_000_000;
60
+ cost += c;
61
+ costSoFar += c;
62
+ }
63
+ else {
64
+ priceable = false;
65
+ }
66
+ }
67
+ }
68
+ }
69
+ return {
70
+ models, totalCalls, taskAttempts, replayedCalls,
71
+ ...(priceable && totalCalls > 0 ? { estCostUsd: cost, replaySavingsUsd: savings } : {}),
72
+ };
73
+ }
package/dist/worker.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type TickDeps } from "./orchestrator.js";
2
+ /** Exponential backoff for task retries: 0.2s doubling per attempt, capped at 60s. */
3
+ export declare const retryDelaySeconds: (attempt: number) => number;
2
4
  export interface WorkerOpts {
3
5
  concurrency?: number;
4
6
  visibilitySeconds?: number;
package/dist/worker.js CHANGED
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
2
2
  import { InvalidationStormError, runTaskLoop, TaskLeaseLostError } from "./loop.js";
3
3
  import { findTaskSpec, tick } from "./orchestrator.js";
4
4
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5
+ /** Exponential backoff for task retries: 0.2s doubling per attempt, capped at 60s. */
6
+ export const retryDelaySeconds = (attempt) => Math.min(60, 0.2 * 2 ** Math.max(0, attempt - 1));
5
7
  /**
6
8
  * Local worker runtime: in-process pollers over the orchestrator and task
7
9
  * queues (local binding). One instance serves one agent or a whole
@@ -138,6 +140,11 @@ export class LocalWorkerRuntime {
138
140
  if (!agent)
139
141
  throw new Error(`no agent registered for ref ${spec.agentRef}`);
140
142
  const run = await deps.store.getRun(msg.runId);
143
+ if (!run || run.status === "cancelled" || run.status === "completed" || run.status === "failed") {
144
+ // A hint for a retired run: messages are never truth.
145
+ await this.shared.queue.ack(d);
146
+ return;
147
+ }
141
148
  await runTaskLoop({
142
149
  store: deps.store, provider: deps.provider,
143
150
  runId: msg.runId, taskId, agent, input: spec.input, files: deps.files,
@@ -165,7 +172,18 @@ export class LocalWorkerRuntime {
165
172
  await this.shared.queue.nack(d, { delaySeconds: 20 });
166
173
  return;
167
174
  }
168
- await this.shared.queue.nack(d, { delaySeconds: 0.2 });
175
+ // An outside dependency failed (a provider, a tool, the network). The
176
+ // retry is durability doing its job; the reason must not vanish into
177
+ // it. Record it on the run so jobs/console answer "why is it stuck"
178
+ // while the retries continue; a later success clears it.
179
+ try {
180
+ const reason = e instanceof Error ? e.message : String(e);
181
+ await deps.store.updateRun(msg.runId, {
182
+ error: `${reason}${msg.taskId ? ` (task ${msg.taskId}, attempt ${d.attempt})` : ""}`,
183
+ });
184
+ }
185
+ catch { /* the store may be the thing that failed */ }
186
+ await this.shared.queue.nack(d, { delaySeconds: retryDelaySeconds(d.attempt) });
169
187
  }
170
188
  catch {
171
189
  // Queue unreachable too: do nothing. The visibility timeout redelivers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toren-run/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {