@render-lab/tasks-agent
v0.3.0
Published
Durable ReAct agent tasks for Render Workflows: agent.plan, agent.step, agent.reflect, agent.loop (tools are durable subtasks).
Readme
@render-lab/tasks-agent
⚠️ Experimental: proof of concept. This package is part of the Render Tasks POC and is published for testing only. It is not fully tested or production ready. Task names, inputs, outputs, and behavior can change or break in any release. Pin exact versions and expect breaking changes.
Durable ReAct agent tasks for Render Workflows. A crash-safe agent loop where every reasoning step and every tool call is a durable subtask — so an agent that dies mid-loop resumes exactly where it left off.
import { loop, registerAgentTool } from "@render-lab/tasks-agent";Eight namespaced tasks:
| Task | Input | Output |
| ----------------------- | ----------------------------------------------------------- | ------------------------------------------------------------ |
| agent.plan | { goal, context?, model? } | { steps: string[], model } |
| agent.step | { goal, history, tools, system?, model? } | { type: "tool"\|"final", tool?, args?, answer?, thought?, model } |
| agent.reflect | { goal, history, model? } | { done, critique, model } |
| agent.loop | { goal, toolNames?, maxSteps?, system?, model? } | { answer, steps, stepCount, stoppedReason } |
| agent.runTool | { tool, args? } | { tool, output } |
| agent.compressHistory | { history, maxWords?, model? } | { summary } |
| agent.route | { goal, routes: { name, description }[], model? } | { route, reasoning? } |
| agent.memory | { op: "get"\|"set", key, value? } | { key, value } (get) / { key, ok: true } (set) |
Each task also exports its raw *Impl (loopImpl, stepImpl, planImpl, reflectImpl, runToolImpl, compressHistoryImpl, routeImpl, memoryImpl) so you can wrap it, re-register it under your own name, or unit-test it with an injected fake.
Orchestration helpers
agent.runToolruns a single registered tool as its own durable subtask — the same dispatchagent.loopuses (resolve by name in the tool registry,await binding.run(args)), but without the ReAct loop. Throws a clear error when the named tool isn't registered. Inject a fake registry viadeps.tools.agent.compressHistorysummarizes a long trace (AgentStepRecord[]) so it fits back into the model's context, composingllm.completethrough the same seam as the reasoning tasks.agent.routepicks one of a set of named routes for a goal (a lightweight classifier / dispatcher). It validates the model's choice against the routes and falls back to the first route on an invalid or unparseable answer; it throws only when no routes are given.agent.memoryis a KV-backed scratchpad that persists across runs. Its KV port is required and injectable — there is no default and no dependency on any KV package:import { memory } from "@render-lab/tasks-agent"; import { redisPort } from "@render-lab/tasks-render-kv"; // structurally compatible KvPort await memory({ op: "set", key: "seen", value: "42" }, { kv: redisPort() }); const { value } = await memory({ op: "get", key: "seen" }, { kv: redisPort() });Inject any port with
{ get(key): Promise<string|null>, set(key, value): Promise<void> }. Calling it withoutdeps.kvthrowsno KV store configured — inject deps.kv.
Install
pnpm add @render-lab/tasks-agent @render-lab/tasks-llm @renderinc/sdk@renderinc/sdk is a peer dependency — one copy, one shared TaskRegistry. @render-lab/tasks-llm is a regular dependency: the agent composes llm.complete for its reasoning and uses extractJson to parse the model's decisions.
Tools are Render tasks (the durability model)
See ADR-0012. A tool is a ToolBinding whose run awaits a wrapped task:
export interface ToolBinding {
spec: AgentTool; // name + description + JSON Schema for args
run(args: { [k: string]: Json }): Promise<string>; // awaits a wrapped task, returns an observation
}Because run awaits a wrapped task (not an impl), each tool call becomes a durable subtask: it gets its own retry policy and dashboard lineage, and it replays from the SDK's checkpoint cache when the loop re-runs. agent.step is a durable subtask too. So agent.loop drives step → tool → step, and if the process crashes mid-loop, the SDK re-runs agent.loop from the top but fast-forwards through every completed step and tool call from checkpoint — resuming exactly where it died.
This only holds while the loop stays deterministic given its history: keep clocks, randomness, and I/O inside the wrapped subtasks, never in the loop body.
Registering tools
A workflow registers its tools once at load. The loop dispatches them by name.
import { registerAgentTool, loop } from "@render-lab/tasks-agent";
import { request } from "@render-lab/tasks-http";
registerAgentTool({
spec: {
name: "fetchUrl",
description: "GET a URL and return its text",
parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
},
// run awaits the wrapped http.request task -> a durable subtask
run: async ({ url }) => (await request({ url: String(url) })).body.slice(0, 4000),
});
const result = await loop({ goal: "research X and summarize", toolNames: ["fetchUrl"], maxSteps: 8 });
// result.answer, result.steps, result.stoppedReason ("final" | "maxSteps")Pass toolNames to restrict the loop to a subset of the registry; omit it to expose every registered tool.
Cost tracking (ledger forwarding)
loop, step, plan, reflect, route, and compressHistory all accept an
optional ledger?: string and forward it, unfolded, into their own underlying
llm.* call. agent.loop forwards it into each agent.step decision it drives;
reflect and compressHistory are standalone tasks a workflow author calls (and
forwards ledger into) directly — loop does not call them itself, and neither
does it forward ledger into the tools it dispatches (those are workflow-author-
defined and forward it only if their own inputs do). This pack does no aggregation
of its own; it exists as the reference implementation of tasks-llm's composition
rule ("accept ledger, forward ledger"). See
@render-lab/tasks-llm's cost-tracking section
for llm.openCostLedger, llm.withLedger, and llm.costReport.
Environment contract
This package defines no env vars of its own. It inherits the LLM contract from @render-lab/tasks-llm:
| Variable | Required | Purpose |
| ------------------- | ------------------------ | ---------------------------------------------------------- |
| ANTHROPIC_API_KEY | for anthropic/* models | Anthropic API key (read lazily by the LLM adapter). |
| OPENAI_API_KEY | for openai/* models | OpenAI API key. |
| LLM_MODEL | optional | Default model when a call passes none (anthropic/claude-opus-4-8). |
Tools bring their own env contracts (e.g. http.request needs none; notion.appendBlock needs NOTION_API_KEY). Credentials are read lazily at first use, per task.
Testing
Tier 1 (*.test.ts, hermetic): stepImpl/planImpl/reflectImpl/compressHistoryImpl/routeImpl are tested with a fake complete; loopImpl and runToolImpl with fake tool bindings; memoryImpl with a fake KV port. No secrets, no network. Cross-package composition (real tool impls with faked ports) lives in the examples/durable-agent package (ADR-0011).
