11 KiB
Friendly agents API (openworker_sdk::agents)
For the agreed next API direction (not implemented yet), see Agent / Session / send / state design. The examples below describe the current API.
Status: development API. Agent compilation, team sessions, run completion,
interrupt_on auto-approval, busy retries and journal streaming run inside the
SDK host (embedded or child process) behind the agent/* and run/* protocol
methods, so other-language SDKs get the same behaviour; this Rust module is a
thin typed wrapper. Wire contract: Agent run protocol.
The Node.js TypeScript SDK wraps those same methods over managed private stdio. It does not duplicate the agent loop, team scheduler, approval policy, or SQL adapters. Rust also supports embedding; TypeScript initially supports a separate Rust process only.
Why
The protocol client is correct but low level: agents are bundle directories
registered at host start, a run is create_session + attach + start_turn +
polling snapshots, approvals are raw interaction/respond calls, and teams are
team_command(operation: String, arguments: Value).
This layer borrows the shape of the OpenAI Agents SDK (an agent is a value;
a run returns a result with typed interruptions you approve and resume) and the
built-ins of LangChain Deep Agents (declarative subagents, a todo tool, a
per-tool interrupt_on map). Durability, recovery and permission checks stay
exactly where they are today.
At a glance
use openworker_sdk::agents::*;
let researcher = Agent::new("researcher")
.description("Finds and verifies facts")
.instructions("Research the assigned item and report evidence.")
.tool("web.search");
let assistant = Agent::new("assistant")
.instructions("Help the user with their workspace.")
.tools(["files.read", "files.write"])
.model("openai", "gpt-6-sol") // named model connection
.planning(true) // durable todo list (update_plan)
.interrupt_on(["files.write"]) // only writes wait for a human
.subagent(researcher); // lead/worker team per session
let harness = Harness::builder().state_dir(state_dir).executable(runner)
.agent(assistant)
.tools(registry) // native / MCP / HTTP providers
.storage(Storage::Sqlite)
.embedded() // or .managed(program, RestartPolicy::default())
.await?;
let session = harness.session("assistant", workspace).await?;
// 1. Simple: handle interruptions inline.
let result = session
.run_with("Tidy the docs folder", |i| match &i.kind {
InterruptionKind::Approval { .. } => Response::Approve(ApprovalDecision::Once),
InterruptionKind::Question { options, .. } => {
Response::Answer(options.first().cloned().unwrap_or_default())
}
})
.await?;
// 2. OpenAI-style: run, inspect interruptions, respond, resume.
let mut result = session.run("Tidy the docs folder").await?;
while result.status == RunStatus::Interrupted {
for i in &result.interruptions {
session.respond(i, ask_the_user(i).await).await?;
}
result = session.resume().await?;
}
// 3. Streaming.
let mut stream = session.run_streamed("Summarise the changes").await?;
while let Some(event) = stream.next().await {
if let RunEvent::Message { agent, text } = event { println!("{agent}: {text}") }
}
let result = stream.finish().await?;
Concept mapping
| This API | OpenAI Agents SDK | Deep Agents | Engine underneath |
|---|---|---|---|
Agent::new(id).instructions(..).tools(..) |
Agent(name, instructions, tools) |
create_deep_agent(...) |
Generated coworker bundle (coworker/manifest.md + tools.json v3) under state_dir/agents/<id> |
.model(connection, model) |
model= |
model= |
Manifest default_model |
.planning(true) |
– | write_todos |
Manifest planning: true (update_plan) |
.subagent(agent) |
handoffs / as_tool |
subagents=[...] |
Lead/worker manifests + a durable Team per session |
.interrupt_on([...]) |
tool approvals / HITL | interrupt_on={...} |
Session ask mode; the host approves unlisted calls once as they occur |
Approvals::Reviewer / Never |
– | – | Session auto-approve / bypass |
session.run(input) → RunResult |
Runner.run → final_output |
agent.invoke |
turn/start + snapshot/event loop |
RunResult.interruptions + resume() |
result.interruptions + resume |
Command(resume=...) |
Pending durable interactions |
run_streamed → RunEvent |
Runner.run_streamed |
stream |
Notification stream (wake-up hints) + snapshots |
SessionRef |
Session |
thread id / checkpointer | Session IDs (+ team ID and worker IDs) you persist |
session.team() → Team |
– | – | Typed team/* requests |
Semantics worth knowing
- Agents are fixed at harness start. Definitions compile into bundles before the host starts. Output is deterministic, so an unchanged agent keeps its bundle digest and existing sessions remain valid. Changing an agent's prompt, tools or role changes the digest and, as with hand-written bundles, requires new sessions for it. The host only rewrites directories it created (marker file).
- At least one tool per agent. The engine requires a bound tool; this is validated up front with a clear error.
interrupt_onis application policy, not an engine bypass. Sessions stay inaskmode; the host answersoncefor calls whose tool is not listed, even when no client is waiting. Each auto-approval is a normal durable interaction record withorigin: app_policyand aRunEvent::AutoApproved, including replay after restart. It is never recorded as a human decision and cannot answer a user question. Engine hard checks (path containment, pinned identities) still apply first. Team and plan tools are not approval-gated by the engine.- When is a run finished? Single agent: the lead turn for this submission is
terminal and nothing is pending. With subagents: every member turn is terminal,
no deliveries are queued/running, and no completion notifications await
processing. Admission is held while observing these facts, so capacity or
missing credentials cannot make queued work look completed. A team that stops
with unassigned/dependency-blocked items open or in review still finishes;
inspect
RunResult.board. - Busy engine. Starting a turn or answering right as a turn parks can hit "checkpointing" or the concurrency limit; the host retries the same idempotent request with back-off for up to 20 seconds.
- Streaming is journal-backed.
run_streamedemits events read withevent/readup to each observed snapshot, so fast turns and restarts lose nothing. The stream channel is unbounded so an unread stream never stalls a run. - Compaction prompts and other non-tool interactions surface as
InterruptionKind::Questionand take anAnswer. Session::cancelcancels active turns only;Team::cancelends a team permanently.- Subagents cannot be started directly (
harness.session("worker")fails); at most 15 per lead, 16 agents per harness, unique display names. - Runs have durable receipts.
run/startjournals the original input and replay cursors before admitting a turn. A terminal result (including the team board and ending cursors) is saved by submission ID. Retrying an old submission returns that result, even after later turns or a host restart. A new run cannot replace an unfinished run; resume/respond/cancel it first. Journal pagination honors both the adapter's byte/count limits and the caller's event limit. - Runs are reconstructed from durable state. Live events are only wake-up
hints; each loop re-reads snapshots, so replay,
Lagged,Gapand child restarts are handled by re-observing. Nothing is resubmitted automatically.run_with_submission(id, input)gives you a stable ID to retry afterError::OutcomeUnknown. - Interruptions span the team. A worker's approval or question surfaces from
the lead session's run, tagged with
agent. - Restart recovery. Persist
session.reference()(serde) and callharness.resume(reference); thensession.resume()continues waiting.
Telemetry
Off by default. HarnessBuilder::otlp(endpoint) or .telemetry(TelemetryConfig { .. })
enables the OTLP/HTTP exporter for the SDK host in both embedded and managed
mode (HostConfig.telemetry). The same otlp::Config is used by the server
reference.
| Field | Meaning |
|---|---|
endpoint |
Base URL; /v1/{traces,metrics,logs} is appended. A base path is allowed (https://cloud.langfuse.com/api/public/otel, Grafana Cloud's /otlp). HTTPS unless loopback. |
headers |
Up to 8. Header::secret(name, ENV_VAR) reads the value from the environment at startup (never from config, URL or argv; marked sensitive, never printed). Header::literal(name, value) for non-secrets. Exporter-managed headers (host, content-*, proxy-* ...) are refused. |
signals |
Any of traces, metrics, logs (default all). Langfuse ingests traces only. |
content |
ContentPolicy { capture, max_bytes, redact_secrets }, see below. |
Content capture (opt-in)
capture |
Adds to spans |
|---|---|
off (default) |
Nothing: timings, outcomes, token counts and hashed IDs only. |
metadata |
gen_ai.request.model, gen_ai.tool.name, gen_ai.operation.name, langfuse.observation.type, session.id (the session hash). No text. |
full |
Also gen_ai.prompt (last 20 history items, tail-truncated), gen_ai.completion, gen_ai.tool.call.arguments, gen_ai.tool.call.result. |
Each full value is truncated to max_bytes (default 8 KiB, max 64 KiB) and,
by default, passed through best-effort secret masking (API-key prefixes,
bearer tokens, PEM private keys). Masking is not a guarantee: full capture
can still contain personal or confidential data, so treat the backend as a
system of record for conversation content and set retention accordingly.
Content is attached to spans only, never to metric labels or the lifecycle log
stream, and is computed lazily so off/metadata cost nothing.
Backends
- Langfuse (direct):
endpoint = https://<region>.cloud.langfuse.com/api/public/otel,Authorizationsecret =Basic base64(public_key:secret_key), literalx-langfuse-ingestion-version: 4,signals = [traces],capture = full(ormetadatafor timings/models only). - Grafana Cloud (direct): OTLP gateway URL ending in
/otlp,Authorizationsecret =Basic base64(instance_id:token). - Datadog: prefer the local Datadog Agent's OTLP receiver
(
http://127.0.0.1:4318, no headers). Datadog's agentless intakes use per-signal paths that this base-URL exporter does not target. - Anything else: an OpenTelemetry Collector on loopback.
Export failures never fail a run; shutdown flushes for at most 5 seconds.
Not in this change (follow-ups)
- Skills on generated agents (copying
coworker/skills/*into bundles). - Async/steerable subagents and
forkmode (needs engine support). - Typed
Agent::tool_fn(...)closures; today tools come fromToolRegistry. - Agent-level guardrails and output schemas (
output_type/response_format). - Session listing/closing helpers on
Harness, and a typed model-connection API.