In-process agents
tubeless/agent builds an ordinary pipeline around a bounded decision loop.
Each turn calls your decide callback, validates its decision, executes a batch
of registered tools, and reduces their outcomes into the next state. A finish
decision validates and returns the agent’s result.
The scripted agent recipe runs without model credentials:
make run FILE=examples/agent.ts ARGS='--question "hello tubeless"'
make run FILE=examples/agent.ts ARGS='--question "missing word"'
make run FILE=examples/agent.ts ARGS='--dry-run --question "preview"'
make plan FILE=examples/agent.ts
It creates a variable number of calls, preserves their decision order, recovers
from a deliberate domain error, and transforms the final answer. Its decide
callback is scripted; application code supplies provider SDKs, prompts, and
response mapping when connecting a model.
Connect a model
The OpenAI agent recipe connects the same tools to
the Responses API using built-in fetch. Its
decision adapter is application code,
with no provider SDK or new Tubeless API. Replace decide: openaiDecision with
another provider’s callback to keep the same tools, state, limits, and execution.
Set OPENAI_API_KEY in your environment, then run:
bun run tubeless -- run --trace logs/openai-agent.ndjson examples/agent-openai.ts -- \
--question "Uppercase red, blue, and green in separate calls. Then count the characters in the uppercase results joined by single spaces."
The example defaults to gpt-4.1-mini; set OPENAI_MODEL to choose another
Responses model with function calling. This command makes paid API requests.
The tools themselves only transform and count text in process.
The model chooses which tools to call, their arguments, and how many calls to
request. A run can uppercase three words in one turn, consume those results in
a count call on the next turn, then finish with RED BLUE GREEN and a count of 14. The call sequence is model-selected; the recipe does not script those turns.
The command prints the validated final answer.
The adapter follows OpenAI’s function calling protocol:
- Tool descriptors come from
context.capabilities. Each function wraps the raw tool argument in{ input }, so even a string-valued tool has an object parameter schema. This recipe’s schemas support OpenAI strict mode. When adapting other tools, check the provider’s supported JSON Schema subset. - An adapter-only
_finishfunction wrapscontext.resultJsonSchemain{ result }. Calling it alone returns a Tubeless finish decision. Combining it with other calls fails before any tools start. - Each request sends the current question and accumulated, validated outcomes.
Tubeless owns the state; this example makes independent model requests with
store: false, without a provider conversation or replayed reasoning items. - Provider call IDs become turn-local Tubeless call IDs. The adapter checks the response envelope and parses arguments; Tubeless checks registered tools, call IDs, budgets, argument schemas, tool results, and the final answer.
The example allows six decisions and twelve tool calls, with up to three tools running concurrently. Each HTTP request has a 30-second deadline and receives the run’s cancellation signal. HTTP errors, incomplete responses, refusals, invalid JSON, and invalid decisions fail the run. There are no automatic provider retries. Error messages omit HTTP response bodies.
The recipe also bounds model input in UTF-8 bytes. Questions may use up to 4 KiB; accumulated observations may use up to 16 KiB of serialized JSON. An oversized question fails before the first model request. Oversized history fails before committing the next state or requesting another decision. Accepted observations remain complete, so later tools can use their exact text. The adapter checks the complete encoded HTTP body against a 32 KiB limit before every request, including instructions, schemas, and JSON escaping. These are application byte budgets; they do not measure a selected model’s token usage.
plan, inspect, and graph need no credentials and make no API requests. The
live example has no preview decision source: --dry-run skips the agent and
fails required finalization because there is no answer. Use the scripted recipe
for a credential-free executable preview. Importing the project also makes no
requests; openai-agent is registered in the
example project.
CI replaces fetch with provider response fixtures while running real Tubeless
tools, reduction, and finalization. Run the command above separately for a live
provider check. Traces record turn/call lifecycles and summaries; prompts, state,
tool output, and credentials are not added to traces by this adapter.
The compiled turn graph stays decide -> calls -> reduce. Its execution history
expands with each chosen batch and turn; a model cannot rewrite dependency edges
or register executable code.
Define tools and an agent
Use defineTool for a handler with a description, inputSchema, outputSchema,
and run(validatedInput, context). Both schemas follow Standard Schema V1.
The input schema must expose Standard JSON Schema input conversion for
draft-2020-12, or supply an explicit inputJsonSchema object. defineAgent
similarly requires a resultSchema with input conversion or resultJsonSchema.
Descriptors are owned, frozen JSON data; explicit descriptors assert agreement
with the validators, which remain authoritative. No schema or model SDK is a
runtime dependency of Tubeless.
Tool names start with a letter and contain letters, digits, underscores, or hyphens, up to 128 characters. A call ID is a nonblank string of at most 256 characters, unique within its turn. Model data can select registered names and supply arguments; it cannot register code or change execution controls.
defineAgent takes id, inputSchema, resultSchema, initialState, and
decide, with optional tools, reduce, dryRun, and limits. It returns a
pipeline with the single step and target agent. Its exact raw input, schema
transformed output, and literal ID work with ordinary fromPipeline,
defineProject, and definePipelineCommand. Automatic CLI flags still require
JSON Schema metadata on the agent’s input schema.
Decisions and state
decide(state, context) may return either of these shapes:
{ kind: "continue", calls: [{ id: "lookup-1", tool: "lookup", input: { query: "example" } }] }
{ kind: "finish", result: "The raw result schema input" }
The return type is intentionally unknown: every decision is checked, including
typed provider responses. AgentDecision<typeof tools, RawResult> is useful for
typed fixtures. Empty batches, duplicate call IDs, unknown names, extra envelope
or call fields, and malformed arguments fail. A decision cannot both finish and
schedule calls. A valid undefined final result is distinct from a missing
result property.
All arguments are validated before any handler in the batch starts. Input
transformations run once; each tool output and final result is validated once.
Independent calls may complete in any order, but reduce(state, outcomes) sees
decision order and runs once after the entire successful batch settles.
Dependencies between calls belong in a later turn.
State is owned by each invocation. Initialization and reducer commits copy and freeze finite primitives, arrays, and plain records; undefined fields are allowed. Cycles, class instances, functions, and resource handles are rejected. Keep clients and file handles in application closures. Reducers return the next state; omitting a reducer preserves state without accumulating observations.
context.turn starts at one. context.stateVersion starts at zero and advances
after each accepted batch, including when the reducer is omitted. The context
contains ordinary step services, immutable capability descriptions/input JSON
Schemas, and the raw final-result JSON Schema. It exposes no tool handlers.
Failure, cancellation, and limits
A handler may throw new ToolError(code, message) for an expected domain failure.
The reducer receives { id, tool, ok: false, error: { code, message } }, while the
tool’s child run retains its failed lifecycle. An arbitrary error with the same
string code is fatal. Tool errors thrown by validators or reducers are fatal too.
Successful outcomes contain { id, tool, ok: true, value } with validated outputs.
| Limit | Default | Scope |
|---|---|---|
maxTurns |
20 | Decision invocations, including finish |
maxCalls |
100 | Whole-batch call admissions in this invocation |
maxDecisions |
100 | Decision callback admissions; excludes provider-internal retries |
maxConcurrency |
1 | Simultaneous tool execution in this invocation |
Limits are safe integers; only maxCalls permits zero. A batch exceeding the
remaining call budget fails before any handler starts. Admitted calls consume
their allowance even if execution stops before dispatch. Finishing on the last
turn succeeds; continuing on that turn fails before tools run. Exhaustion fails
with TUBELESS_AGENT_LIMIT_REACHED, including bound, consumed count, requested
count, and scope in the error. Invalid decisions and state use
TUBELESS_AGENT_INVALID_DECISION and TUBELESS_AGENT_INVALID_STATE. Ordinary
pipeline child-error wrapping retains these source codes in the cause chain.
Cancellation stops new calls and waits for active work to settle. Fatal failures
also stop dispatch and drain active work, without cancelling sibling handlers.
Neither case calls the reducer or starts another decision. Handlers must cooperate
with context.signal; Tubeless cannot terminate an uncooperative promise.
Planning never initializes state or calls models, argument validators, or tools.
A dry run skips the agent unless dryRun(state, context) supplies a preview
decision source. Tools skip by default and require their own preview handler.
A skipped required tool or agent output fails finalization instead of inventing
an observation or answer.
Inspection and recordings
Plans show the bounded turn pipeline and the agent’s capability inventory and
limits. Runtime call IDs are not selectable --step targets. Definition identity
includes capabilities, input/result descriptor fingerprints, limits, and tool
pipeline identities. Set implementationVersion when changing prompts,
validators, reducers, or handlers whose semantics cannot be inferred from the graph.
Execution uses ordinary decide, calls, and reduce steps, with a new child run
per turn and per dispatched tool. Turn iteration relations identify the owning
agent. A tool run’s parentRunId identifies its turn, and itemKey retains the
call ID. First-attempt trace attributes record agent.runId, agent.turn,
agent.callId, agent.tool, and agent.parentAttemptId. Decision, admission,
and reduction attempts record bounded decision/count/state-version summaries.
These existing trace v3 records round-trip through NDJSON and SQLite. State,
prompts, inputs, and outputs are not recorded automatically. Live progress keeps
at most 32 visible call groups and 32 recent turn groups.
This release slice executes handler tools in process. pipelineTool, subagents,
delegation depth, and shared tree admission arrive in the next stage. Agent
limits currently apply to one invocation; calling another agent manually from a
handler does not share them. Crash-safe resume remains a later stage of the harness.