@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 +2 -0
- package/dist/index.js +2 -0
- package/dist/loop.d.ts +4 -0
- package/dist/loop.js +18 -3
- package/dist/model.d.ts +2 -0
- package/dist/orchestrator.d.ts +7 -0
- package/dist/orchestrator.js +28 -2
- package/dist/tail.d.ts +16 -0
- package/dist/tail.js +27 -0
- package/dist/usage.d.ts +29 -0
- package/dist/usage.js +73 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.js +19 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
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 }, () =>
|
|
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 }, () =>
|
|
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 {
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -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<{
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -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
|
-
|
|
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
|