FIFTY AGENT SDK
i kept rebuilding the same agent plumbing: the loop, parser, limits and state. Fifty Agent SDK factors that runtime out once, so the application can own its tools and decisions instead.
THE WORK
// 01 · PROBLEM · APPROACH · OUTCOMEThe plumbing kept coming back.
each agent project brought the same work back: model access, tool dispatch, response parsing, conversation state and a way to see what happened. that repetition was the reason to separate the runtime.
i wanted the next application to define its own tools and decisions without rebuilding those boundaries. this is a design motivation, not a measured claim about time or code saved.
Separate the runtime from the application.
typed extension contracts separate model clients, tools and StateStore, the conversation-history contract. a shared registry, the name-based tool boundary, connects local functions and MCP, Model Context Protocol for remote tools.
the ReAct model/tool execution loop uses a parser to interpret responses. text and native calling modes, configurable execution limits and branchable history sit behind that loop; application policy stays with the caller.
Published, with inspectable boundaries.
fifty-agent-sdk 1.5.0 is published on PyPI under MIT. the source implements Memory, SQL and Redis state stores, with optional persistence dependencies rather than a required database for every application.
the public source snapshot has historical CI receipts across Python 3.11–3.14. those receipts describe a tested snapshot, not deployment scale; the numbers below keep the date, skips and release boundary visible.
ARCHITECTURE
// 02 · LOOP DIAGRAM
// FIG.01 · FIFTY AGENT SDK · RUNTIME BOUNDARIES · REV.01
generated architecture illustration based on pinned source; not a runtime capture.
state options are Memory, SQL and Redis. SQL needs an async driver and consumer-provisioned schema; Redis needs a service and TTL policy. typed events return to the caller, outside this structural slice. optional audits and hooks are omitted here and operate on a best-effort basis.
For small-screen reading, open the full-resolution figure or use the text equivalent below.
Read the architecture as text
Seven components: consumer app, AgentRunner, AgentLoop, model/client, Parser, tool registry (local + MCP), StateStore.
- consumer app → AgentRunner: invokes run.
- AgentRunner → AgentLoop: drives execution.
- AgentLoop → model/client: requests completion.
- AgentLoop → Parser: interprets response.
- AgentLoop → tool registry: invokes named tools; local + MCP share this boundary.
- AgentRunner ↔ StateStore: loads and persists conversation history.
These are responsibility boundaries, not an exhaustive timing trace.
STACK
// 03· WHAT IT'S MADE OFthe model adapter targets a chat-completions contract, not every endpoint. token events replay after parsing a terminal completion; they do not expose every hidden thought live. the loop's stream path does not support native calls.
PROCESS
// 04 · SDK API EXCERPTSsource-backed Python selections, not standalone programs or executions recorded here. imports and surrounding async setup are omitted where noted. no output trace is supplied.
# imports omitted; weather data is a fixed fixture
@tool()
async def get_weather(city: str) -> dict[str, Any]:
"""Return the current weather for a city."""
return {"city": city, "temp_c": 21}
registry = Registry()
InProcProvider([get_weather]).register_into(registry)annotations define the local tool input. InProcProvider groups decorated tools into the same registry used by remote tools; the weather value here is hardcoded, not fetched.
Python fixture tool accepting a city, followed by bulk registration through InProcProvider. No execution output.Excerpt scope and setup
function copied from quickstart; registration adapted to the inspected bulk-provider API. Imports needed: Any from typing; tool, Registry and InProcProvider from fifty_agent_sdk. Not a verbatim contiguous excerpt.
# imports and configured llm/registry omitted
loop = AgentLoop(
llm=llm,
registry=registry,
parser=JsonModeParser(),
prompts=PromptSections(persona="You are helpful."),
safety=SafetyConfig(),
model="gpt-4o",
output_format=JSON_MODE_OUTPUT_FORMAT,
)
runner = AgentRunner(
loop=loop,
state=MemoryStateStore(),
system_prompt="You are a helpful weather assistant.",
)pair the parser with its output-format prompt, then give the runner its conversation store. the model string belongs to the published example, not a new provider choice.
AgentLoop construction with JSON parser, matching format prompt and safety configuration; AgentRunner receives that loop and MemoryStateStore.Excerpt scope and setup
quickstart constructors with intervening explanatory comments omitted. Uses quickstart imports; llm and registry must already be configured. The following tile drives runner.run.
# inside async setup, using the runner above
async for event in runner.run(
"session-1", "What's the weather in Paris?"
):
if event.event_type == "tool_failed":
print(event.error)
elif event.event_type == "final":
print(event.text)consume typed events and handle their discriminants. this selection prints recoverable tool failures and final text without breaking on the final event. ToolProgressEvent is reserved, not emitted.
asynchronous runner iteration selecting tool_failed and final event fields. Printed values are not shown or claimed to have been observed.Excerpt scope and setup
async iteration adapted from quickstart; field handling selected from event definitions, not a verbatim repository block. seeing FinalEvent alone does not prove persistence; exhaust iteration normally and manage early-close/cancellation explicitly. fatal errors may propagate.
# existing loop and session_id with at least four messages
store = runner.state
branch = await store.fork(session_id, from_sequence=4)
await store.switch_branch(session_id, branch)
await store.append(
session_id, ChatMessage(role="user", content="...edited...")
)
await store.get_messages(session_id, branch_id="trunk")runner.state exposes the exact supplied store, not a wrapper. fork, switch and append form an application-side edit flow across Memory, SQL and Redis; the caller coordinates concurrent turns and branch changes.
obtains the runner's store, forks after sequence four, switches branch, appends an edited user message and reads the original trunk history.Excerpt scope and setup
source-backed selection using runner.state in place of pre-existing store; line wrapping adapted. Import ChatMessage from fifty_agent_sdk. Requires existing session history and async context; not an all-backend initialization example.
Runtime boundaries and source notes
calling and repair
JSON and prose parsers interpret explicit response formats. JSON cleanup is distinct from the default one text-format repair retry per iteration; native parse failures do not enter that retry. opt-in native tool batches use bounded concurrency, defaulting to one, and count as one iteration. optional tool-use reconsideration is a nudge, not forced grounding.
histories
iterative lineage traversal avoids recursive Python stack growth, not the cost of materializing deep histories. Memory is process-local; branch-tail truncation does not erase inherited history. conversation storage is not an in-flight execution checkpoint.
remote tools
MCP uses Streamable HTTP. refresh is manual or opt-in background work; removed upstream tools remain registered. refresh between runs and rebuild the loop when prompt-described tools change. per-call error screening controls model-facing error text, not transport failures or a security guarantee.
visibility
source review flags the runner's single-slot audit/hook correlation for native multi-call batches. this is a static limitation, not a failure reproduced here; per-call audit timing and arguments need further verification.
NUMBERS
// 05 · WHAT SHIPPEDthe audited source is two commits after the release tag, with no runtime src changes reported in that comparison. wheel/source byte equivalence was not checked. historical CI counts are per job, not summed across versions.
LESSONS
// 06 · WHAT TO TAKE FORWARDFactor the plumbing, not the application.
the repeated work was the runtime: model access, parsing, tools and state. extracting those boundaries leaves the application responsible for its own decisions, rather than turning every project into another loop implementation.
Keep one tool invocation boundary.
local functions and remote tools share a name-based registry. the invocation surface stays consistent while validation, transport and error behavior remain explicit at their own boundaries.
Name the limits precisely.
iteration caps and per-tool timeouts constrain specific operations. they are not a total-runtime guarantee; model calls, hooks, cancellation and fatal errors still need application-level handling.
Share the store you supplied.
runner.state exposes the exact constructor-supplied store and its serialization. it does not coordinate whole concurrent turns or branch changes for the application. changing stores means constructing another runner.
BUILD YOUR OWN AGENT
the package, source and quickstart are public. start with the runtime boundaries, then bring the tools and application policy your agent needs.