Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
## [Unreleased]

### Added
- **TypeScript SDK: crash-resume.** `Runtime.resumeRun(runId, fn)` re-attaches a
Node/TypeScript agent to an existing governed run after a crash — it neither
creates a new run nor cancels on error, so the daemon's restored budget and usage
continue enforcing without re-spending. Mirrors the Python SDK's `resume_run`; read
`run.latestCheckpoint()` to resume from where the agent left off. See
[`docs/RESUME.md`](docs/RESUME.md) and [`sdks/typescript`](sdks/typescript).
- **Point a provider at a custom upstream.** Set `RISKKERNEL_OPENAI_BASE_URL` or
`RISKKERNEL_ANTHROPIC_BASE_URL` to route that provider through an OpenAI-compatible
gateway, a corporate proxy, or a local mock (e.g. for benchmarking) instead of its
Expand Down
30 changes: 27 additions & 3 deletions sdks/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ deterministic decision (budgets, halts, approval policy); this package just make
governed runs ergonomic from Node/TypeScript. **No runtime dependencies** — it uses
the global `fetch` (Node 18+), the same stdlib-only ethos as the Python SDK.

> **Status:** core client — run control, budgets, the governing proxy, and approval
> gates. Crash-resume (`resumeRun`), framework adapters (Vercel AI SDK), and npm
> publishing are tracked in the repo issues (**#80–#82**) — contributions welcome.
> **Status:** core client — run control, budgets, crash-resume (`resumeRun`), the
> governing proxy, and approval gates. Framework adapters (Vercel AI SDK) and npm
> publishing are tracked in the repo issues (**#81–#82**) — contributions welcome.

## Use

Expand Down Expand Up @@ -37,10 +37,34 @@ A budget halt surfaces as `BudgetExceeded` (`reason` is the machine-readable
HaltReason, e.g. `dollar_budget_exceeded`). The run is cancelled automatically if
the body throws — pass `cancelOnError: false` to opt out.

## Resume after a crash

The daemon reloads non-terminal runs on restart with the budget and usage they had
already spent, so a `SIGKILL`'d run keeps enforcing without re-spending. Reattach to
it by id with `resumeRun` and pick your work back up from the last checkpoint:

```ts
await rt.resumeRun(runId, async (run) => { // attaches; never creates or cancels
const cp = await run.latestCheckpoint(); // the state you saved before the crash
const start = (cp?.payload?.cursor as number) ?? 0;
for (let i = start; i < total; i++) { // skip the steps you already paid for
await run.step(); // counts against the SAME budget
// ... your work ...
await run.checkpoint("step", { cursor: i + 1 });
}
});
```

The run resumes against whatever budget it had left, so it can't overspend by
restarting — `run.step()` still throws `BudgetExceeded` at the original ceiling. The
run id is the only thing to keep across a restart (a file, your job queue, a DB row);
see [`docs/RESUME.md`](../../docs/RESUME.md) for the full model.

## API

- `new Runtime(opts)` — `{ baseUrl, token, approvalPollIntervalMs, approvalTimeoutMs }`.
- `rt.governedRun({ name?, budget?, metadata?, cancelOnError? }, async (run) => …)`.
- `rt.resumeRun(runId, async (run) => …)` — re-attach to an existing run after a crash.
- `run.step()` · `run.checkpoint(name, payload)` · `run.latestCheckpoint()` ·
`run.cancel(reason)` · `run.status()` · `run.proxyConfig()` · `run.approve(tool, opts)`.
- `RiskKernel` — the low-level `/v1` client, for manual control.
Expand Down
29 changes: 29 additions & 0 deletions sdks/typescript/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,35 @@ export class Runtime {
throw e;
}
}

/**
* Re-attach to an existing governed run by id — the resume path after a crash.
*
* Unlike {@link governedRun}, this neither creates a new run nor cancels on
* error: the daemon reloads non-terminal runs on restart with the budget and
* usage they had already spent, so enforcement continues without re-spending.
* Read {@link Run.latestCheckpoint} to pick the work back up where it left off:
*
* ```ts
* await rt.resumeRun(runId, async (run) => {
* const cp = await run.latestCheckpoint();
* const start = (cp?.payload?.cursor as number) ?? 0;
* for (let i = start; i < total; i++) {
* await run.step(); // counts against the SAME budget
* // ... your agent's work ...
* await run.checkpoint("progress", { cursor: i + 1 });
* }
* });
* ```
*
* The run id is the only thing the agent must keep across a restart. Throws
* {@link APIError} (404) if the run id is unknown.
*/
async resumeRun<T>(runId: string, fn: (run: Run) => Promise<T>): Promise<T> {
const data = await this.client.getRun(runId);
const run = new Run(this.client, data, this.pollMs, this.timeoutMs);
return fn(run);
}
}

function toBudgetDict(b?: Budget): Record<string, number> | undefined {
Expand Down
67 changes: 66 additions & 1 deletion sdks/typescript/test/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Runtime } from "../src/index";

// A tiny in-process mock of the daemon's /v1 API, so the SDK is exercised over
// real HTTP with no daemon, no keys.
const state = { steps: 0, haltAfter: Number.POSITIVE_INFINITY, cancelled: false };
const state = {
steps: 0,
haltAfter: Number.POSITIVE_INFINITY,
cancelled: false,
checkpoint: null as { name: string; payload: Record<string, unknown> } | null,
};
let server: Server;
let baseUrl = "";

Expand All @@ -27,11 +32,25 @@ beforeAll(async () => {
}
return send(res, 200, { stepIndex: state.steps++ });
}
if (method === "POST" && url.endsWith("/checkpoints")) {
const body = buf ? JSON.parse(buf) : {};
state.checkpoint = { name: body.name ?? "", payload: body.payload ?? {} };
return send(res, 200, {});
}
if (method === "POST" && url.endsWith("/cancel")) {
state.cancelled = true;
return send(res, 200, { id: "run-test", status: "cancelled" });
}
if (method === "GET" && url.startsWith("/v1/runs/")) {
// Resume path: GET /v1/runs/{id} reloads a run with the usage it already
// spent. Unknown ids 404 (mirrors the daemon).
if (url === "/v1/runs/run-test") {
return send(res, 200, { id: "run-test", status: "running", usage: { loops: state.steps } });
}
return send(res, 404, { code: "not_found", message: "unknown run" });
}
if (method === "GET" && url.startsWith("/v1/checkpoints/")) {
if (state.checkpoint) return send(res, 200, state.checkpoint);
return send(res, 404, { code: "not_found", message: "no checkpoint" });
}
return send(res, 404, { code: "not_found", message: "unhandled" });
Expand All @@ -48,6 +67,7 @@ function reset(): void {
state.steps = 0;
state.haltAfter = Number.POSITIVE_INFINITY;
state.cancelled = false;
state.checkpoint = null;
}

describe("Runtime", () => {
Expand Down Expand Up @@ -100,4 +120,49 @@ describe("Runtime", () => {
expect(await run.latestCheckpoint()).toBeNull();
});
});

it("resumeRun re-attaches to a crashed run and continues from its checkpoint without re-spending", async () => {
reset();
// Simulate a run that crashed after 3 paid steps, having checkpointed cursor 3.
state.steps = 3;
state.checkpoint = { name: "progress", payload: { cursor: 3 } };
const rt = new Runtime({ baseUrl });

const seen: number[] = [];
await rt.resumeRun("run-test", async (run) => {
expect(run.id).toBe("run-test");
const cp = await run.latestCheckpoint();
const start = (cp?.payload?.cursor as number) ?? 0;
expect(start).toBe(3); // skip the work already paid for
for (let i = start; i < 6; i++) {
seen.push(await run.step()); // steps count against the SAME budget — no reset
await run.checkpoint("progress", { cursor: i + 1 });
}
});

// The step counter continued at 3 rather than restarting at 0.
expect(seen).toEqual([3, 4, 5]);
expect(state.steps).toBe(6);
expect(state.cancelled).toBe(false);
});

it("resumeRun does NOT cancel the run when the body throws", async () => {
reset();
state.steps = 1;
const rt = new Runtime({ baseUrl });
await expect(
rt.resumeRun("run-test", async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(state.cancelled).toBe(false); // resume must never cancel a run it didn't start
});

it("resumeRun surfaces APIError(404) for an unknown run id", async () => {
reset();
const rt = new Runtime({ baseUrl });
await expect(
rt.resumeRun("does-not-exist", async () => "unreachable"),
).rejects.toMatchObject({ name: "APIError", status: 404 });
});
});
Loading