Agents & the loop
Agent declares what the agent is (model, instructions, tools); run executes the ReAct loop
until the model produces a final answer; Result is the receipt — output, steps, tokens, and
decimal cost. Nothing here requires governance; everything here is what governance attaches to.
Quickstart
from cendor.sdk import Agent, tool, run
@tool
def search(query: str, top_k: int = 3) -> list[str]:
"""Search the knowledge base."""
...
agent = Agent(name="assistant", model="gpt-4o", tools=[search],
instructions="Answer using tools when helpful.")
result = run(agent, "What's our refund policy?")
print(result.output)
import { Agent, tool, run } from '@cendor/sdk';
import { z } from 'zod';
const search = tool(async ({ query, topK }) => { /* ... */ }, {
name: 'search',
description: 'Search the knowledge base',
parameters: z.object({ query: z.string(), topK: z.number().default(3) }),
});
const agent = new Agent({ name: 'assistant', model: 'gpt-4o', tools: [search],
instructions: 'Answer using tools when helpful.' });
const result = await run(agent, "What's our refund policy?");
console.log(result.output);
Core concepts
Agent — declarative, provider-inferred
Agent(
name: str,
model: str, # any supported model id: "gpt-4o", "claude-opus-4-8", ...
instructions: str = "", # the system prompt
tools: list = [], # @tool-decorated callables or Tool objects
provider: str | None = None, # override provider inference from the model id
output_type: type | dict | None = None, # structured output (dataclass / JSON schema)
max_turns: int = 8, # ReAct loop bound (termination guarantee)
context_budget: int | None = None, # assemble history to a token budget via contextkit
temperature: float | None = None,
max_tokens: int | None = None,
)
new Agent({
name: string,
model: string, // any supported model id: 'gpt-4o', 'claude-opus-4-8', ...
instructions?: string, // the system prompt
tools?: (Tool | ToolFn)[], // tool(...)-wrapped functions
provider?: string, // override provider inference from the model id
outputType?: ZodType | object, // structured output (zod schema / raw JSON schema)
maxTurns?: number, // ReAct loop bound, default 8 (termination guarantee)
contextBudget?: number, // assemble history to a token budget via contextkit
temperature?: number,
maxTokens?: number,
})
The provider is inferred from the model id (gpt-*/o* → OpenAI, claude-* → Anthropic,
gemini-* → Google, …); pass provider= to override. Hugging Face and Azure AI Foundry ids
aren’t prefix-inferable, so those always take an explicit provider= — see
Providers.
api_key / base_url / client are also accepted: api_key falls back to the provider’s env
var, base_url targets a gateway or self-hosted endpoint, and client hands over a pre-built SDK
client (instrumented on adoption, so budgets/guard/audit still apply). Agent(cache=True) marks
the stable prefix (system prompt + tools) for provider prompt caching — Anthropic cache_control
today, a no-op elsewhere — and cached tokens price through to Result.cost automatically.
tool — schema from the function itself
@tool (Python) / tool(...) (TypeScript) turns a plain function into a Tool. In Python the
JSON Schema comes from the type hints and the description from the docstring; in TypeScript —
no runtime type hints — the schema is a zod object, the same pattern as the
Vercel AI SDK. Sync and async both work.
from cendor.sdk import tool
@tool
def search(query: str, top_k: int = 3) -> list[str]:
"""Search the knowledge base."""
...
@tool(name="lookup")
async def fetch(url: str) -> str:
"""Fetch a URL."""
...
import { tool } from '@cendor/sdk';
import { z } from 'zod';
const search = tool(async ({ query, topK }) => { /* ... */ }, {
name: 'search',
description: 'Search the knowledge base',
parameters: z.object({ query: z.string(), topK: z.number().default(3) }),
});
// name defaults to the function's name; async and sync tools both work
Either way the schema is formatted per provider automatically (OpenAI functions, Anthropic
tools, Gemini function declarations, Bedrock toolConfig), and every execution flows through cendor-core’s
instrument_tool, emitting a ToolCall on the bus — correlated by trace_id, recorded by the
audit chain, replayable by cassette.
run — the loop, bounded
run(agent, input, *, session=None, audit=None, max_turns=None, retry=None, on_step=None) -> Result
await run.aio(agent, input, ...) # async — same signature
await run(agent, input, { session?, audit?, maxTurns?, retry?, onStep? }) // -> Result
// TS is async throughout; run.stream / run.astream yield events (see Streaming below)
input— a string or a list of messages.session— aSessionfor multi-turn memory (Memory & sessions).audit— anAuditLog; each agent step is wrapped in an acttracedecision()so the chain correlates everyllm_call/tool_callbydecision_idand the run’strace_id.retry— aRetryPolicyfor transient failures (Production hardening).on_step— a live-progress callback, invoked with eachStepas it completes.
max_turns (default 8) bounds the loop — the termination guarantee. A run that ends without a
final answer (e.g. max_turns hit mid tool-loop) sets Result.incomplete = True.
Result — the receipt
result.output # final answer (str) or the parsed structured object
result.steps # list[Step] — one per LLMCall/ToolCall, in order, correlated by trace_id
result.llm_steps # the model turns
result.tool_steps # the tool executions
result.usage # aggregate Usage across the run
result.cost # aggregate Money (Decimal) across the run
result.trace_id # the run id every step shares
result.messages # the full conversation (canonical/OpenAI-shape messages)
result.incomplete # True when the run ended without a final answer
result.output // final answer (string) or the parsed structured object
result.steps // Step[] — one per LLMCall/ToolCall, in order, correlated by traceId
result.llmSteps // the model turns
result.toolSteps // the tool executions
result.usage // aggregate usage across the run
result.cost // aggregate Money (decimal) across the run
result.traceId // the run id every step shares
result.messages // the full conversation (canonical/OpenAI-shape messages)
result.incomplete // true when the run ended without a final answer
Each Step wraps the actual LLMCall/ToolCall from the bus (.call), with .agent, .kind
("llm"/"tool"), and .name (model id or tool name).
Structured output
output_type accepts a dataclass or Pydantic model (Python), a zod schema (TypeScript), or a
raw JSON-schema object in either language. The schema is sent via each provider’s native
structured-output feature — OpenAI json_schema, Ollama format, Gemini response_schema;
Anthropic/Bedrock embed it in the JSON instruction — which is far more reliable than a bare
“respond with JSON”. The final message is parsed into the requested type:
from dataclasses import dataclass
@dataclass
class Weather:
city: str
conditions: str
agent = Agent(name="w", model="gpt-4o", instructions="Report weather.", output_type=Weather)
result = run(agent, "Weather in Paris?")
assert isinstance(result.output, Weather)
import { z } from 'zod';
const Weather = z.object({ city: z.string(), conditions: z.string() });
const agent = new Agent({ name: 'w', model: 'gpt-4o', instructions: 'Report weather.',
outputType: Weather });
const result = await run(agent, 'Weather in Paris?');
// result.output is the JSON-parsed object, validated against the zod schema
Streaming
run.stream (sync) / run.astream (async) yield events as the run progresses; the terminal
RunComplete event carries the same Result a blocking run() returns. Token-by-token
reassembly is native for the OpenAI family + Ollama (tool-call deltas included); other providers
fall back to a whole-response delta. Multi-agent handoff runs stream too
(Multi-agent).
from cendor.sdk import Agent, run, TextDelta, ToolCallEvent, RunComplete
agent = Agent(name="a", model="gpt-4o", instructions="Be brief.")
for event in run.stream(agent, "Tell me a joke"):
if isinstance(event, TextDelta):
print(event.text, end="", flush=True)
elif isinstance(event, ToolCallEvent):
print(f"\n[calling {event.name}({event.arguments})]")
elif isinstance(event, RunComplete):
print("\ncost:", event.result.cost)
import { Agent, run, TextDelta, ToolCallEvent, RunComplete } from '@cendor/sdk';
const agent = new Agent({ name: 'a', model: 'gpt-4o', instructions: 'Be brief.' });
for await (const event of run.stream(agent, 'Tell me a joke')) {
if (event instanceof TextDelta) process.stdout.write(event.text);
else if (event instanceof ToolCallEvent) console.log(`\n[calling ${event.name}]`);
else if (event instanceof RunComplete) console.log('\ncost:', event.result.cost?.toString());
}
Multimodal input
A message’s content may be a parts list (OpenAI shape). The OpenAI family passes it through
natively; Anthropic and Gemini translate images to their block formats (base64 or URL); Bedrock
keeps the text.
run(agent, [{"role": "user", "content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}},
]}])
await run(agent, [{ role: 'user', content: [
{ type: 'text', text: "What's in this image?" },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,....' } },
] }]);
How it works
One turn of the loop, top to bottom — governance fires at the seam, not inside your code:
%%{init: {"flowchart": {"htmlLabels": false}} }%%
graph TD
IN["run(agent, input)"]
ASM["assemble context<br/>(session history; contextkit if context_budget set)"]
FMT["format for the provider<br/>(messages + tool schemas)"]
CALL["the model call<br/>inside trace(run_id)"]
PRE["pre-flight: budget / guard<br/>(block · downgrade · redact)"]
NORM["normalize the response<br/>(one canonical shape)"]
TOOLS{"tool calls<br/>requested?"}
EXEC["execute tools<br/>(a ToolCall per call, on the bus)"]
DONE["finalize -> Result<br/>(output · steps · usage · cost)"]
IN --> ASM --> FMT --> PRE --> CALL --> NORM --> TOOLS
TOOLS -->|yes| EXEC --> ASM
TOOLS -->|"no (or max_turns)"| DONE
classDef seam fill:#2563EB,color:#ffffff,stroke:#1E40AF;
classDef stop fill:#F43F5E,color:#ffffff,stroke:#E11D48;
class CALL seam;
class PRE stop;
Every model call runs inside trace(run_id), so usage and cost are captured on the bus and every
subscriber — budgets, audit, cassette — sees the same correlated events.
Plugs into the stack
The loop is the composition point: context_budget pulls in
contextkit (and squeeze when installed), governance
wrappers pull in tokenguard and acttrace, and
cassette records/replays the whole trajectory. All through
cendor-core’s seams — the SDK contains no governance logic of its own.
Honest limits
- The loop is bounded, not clever.
max_turnsis the only termination guarantee; a model that never answers finishesincomplete, it doesn’t error. - Token-level streaming is OpenAI-family + Ollama. Other providers stream one whole-response delta — same events, coarser granularity.
- Provider inference is by model-id prefix. Hub ids and deployment names (Hugging Face,
Azure) always need an explicit
provider=— see Providers. - Structured output is provider-mediated. Where a provider has no native JSON-schema mode, the schema rides the instruction — reliable in practice, but not a grammar-level guarantee.