Skip to main content

fifty-agent-sdk — a reusable agent loop for python.

fifty-agent-sdk

PyPI Python CI License: MIT

fifty-agent-sdk is a reusable agent loop for python. it implements a custom reACT loop with json-mode tool calls, an mcp client, and pluggable llm, state, and tool backends. it exists because the loop, the parser, the safety checks, and the runner kept getting rewritten per project. this is that loop, factored out once: write the tools, hand them to the runner, let it iterate.

At a glance

  • talks to any openai-compatible chat-completions endpoint by swapping one base_url: openai, google distributed cloud, a local oss server.
  • llm clients, state stores, and tools are pluggable behind protocols: bring your own, the loop stays the same.
  • the run emits a typed event stream the caller consumes, so you watch the react loop step by step.
  • agent skills: SKILL.md directories listed in the system prompt by name and description, loaded by the model on demand through a built-in load_skill tool.
  • an iteration cap and per-tool timeouts bound every run, with a fallback answer when a run hits the cap and a separate one when it errors: a loop that can't end is a loop that doesn't ship.
  • zero-infra by default: no db, no redis, until you opt into an extra.

Installation

pip install fifty-agent-sdk

Optional extras:

  • pip install 'fifty-agent-sdk[sql]' — enables SqlStateStore, SqlAuditSink, SQLAlchemy
  • pip install 'fifty-agent-sdk[redis]' — enables RedisStateStore

Importing fifty_agent_sdk pulls neither extra; the extra symbols are re-exported lazily, and first access without the relevant extra installed raises a clear ImportError. The sql extra installs SQLAlchemy but not a database driver — bring your own async driver (e.g. aiosqlite for SQLite, asyncpg for PostgreSQL).

Requires Python >=3.11.

Quickstart

the example builds a tool, hands it to the AgentRunner, and consumes the typed event stream the run emits.

import asyncio
from typing import Any

from fifty_agent_sdk import (
    AgentLoop,
    AgentRunner,
    MemoryStateStore,
    OpenAICompatibleClient,
    PromptSections,
    Registry,
    SafetyConfig,
    ToolMode,
    tool,
)


@tool()
async def get_weather(city: str) -> dict[str, Any]:
    """Return the current weather for a city."""
    return {"city": city, "temp_c": 21}


async def main() -> None:
    # 1. An LLM client — points at any OpenAI-compatible endpoint.
    #    Pass base_url=... to target GDC or a local OSS server instead of OpenAI.
    llm = OpenAICompatibleClient(api_key="sk-...")

    # 2. A tool registry — register the decorated tool.
    registry = Registry()
    registry.register(get_weather)

    # 3. The ReACT loop — LLM + registry + prompts + safety + tool mode.
    #    `tool_mode` picks how the model calls tools. ToolMode.JSON supplies
    #    the JSON parser and teaches the model its envelope; switch to
    #    ToolMode.NATIVE for provider function calling (see "tool modes").
    loop = AgentLoop(
        llm=llm,
        registry=registry,
        prompts=PromptSections(persona="You are helpful."),
        safety=SafetyConfig(),
        model="gpt-4o",
        tool_mode=ToolMode.JSON,
    )

    # 4. The runner — wraps the loop with conversation-state persistence.
    runner = AgentRunner(
        loop=loop,
        state=MemoryStateStore(),
        system_prompt="You are a helpful weather assistant.",
    )

    # 5. Drive a turn and consume the event stream.
    async for event in runner.run("session-1", "What's the weather in Paris?"):
        print(event)


asyncio.run(main())

Core concepts

tools

the registry of functions the agent can call. each tool is a side-effecting action exposed to the loop, so the model can do something in the world and not just talk about it.

a tool's result reaches the model as text: a string as it is, and anything else as json (non-ascii text kept literal), or as its repr when json cannot encode it. since 1.11.0 a result nested deeper than json.dumps reaches (repr is not tried for it), or one whose repr raises, is sent as the fixed sentence "The tool's output could not be converted to text, so it is not shown." with the warning tool_output_not_rendered, which carries the call and run ids and type names, never the value or the exception text, and the run continues. also since 1.11.0, a surrogate code point (U+D800-U+DFFF) in that text, which utf-8 cannot encode, is sent as its six-character \udXXX escape, the spelling json uses, which the model cannot tell from the same six characters typed, and so is one in an error text, an after_tool note or a denial reason; other text is unchanged. events still carry the tool's own value and text.

tool modes

how the model calls tools, set with one value: AgentLoop(tool_mode=ToolMode.JSON | ToolMode.PROSE | ToolMode.NATIVE). the mode sets the parser, the output format, the role tool results go back in, how tools are declared, and the retry reminder, all together. switching protocol means changing one value.

JSON PROSE NATIVE
tools declared via tools param no no yes, tool_choice="auto"
prompt tool block rendered rendered suppressed
text parser JsonModeParser ProseModeParser final-only: text without tool_calls is the answer
output format JSON_MODE_OUTPUT_FORMAT PROSE_MODE_OUTPUT_FORMAT none (plain-text final)
tool-result role "assistant" ("user" allowed) same as JSON always "tool", paired by tool_call_id
stream=True allowed allowed rejected at construction

NATIVE follows the openai-compatible function-calling protocol. a response with tool_calls is a tool turn, and any other text is the final answer, word for word. a text-shaped tool call (a json {"action": "tool", ...} envelope, a prose Action: block) is never run, so a role="tool" message always follows an assistant turn that carries its id. an empty response gets one retry with a native reminder; if that fails too, the run ends with a ParserError event and the error fallback answer. with an empty registry, tools is left out of the request.

a mode owns its knobs. you can still pass one when it fits the mode, such as a custom parser that wraps JsonModeParser under JSON, tool_message_role="user" for chat templates that need strict user/assistant alternation, or an output format like "answer in markdown" under NATIVE. a value that belongs to another mode raises ValueError when the loop is built. the sdk never silently picks one:

AgentLoop(..., tool_mode=ToolMode.NATIVE, parser=JsonModeParser())
# ValueError: tool_mode=ToolMode.NATIVE conflicts with parser=JsonModeParser(): under NATIVE
# a tool call is only ever a structured tool_calls entry, and a text parser could dispatch a
# text tool call. Drop parser=; NATIVE supplies its own final-only text parser.

omit tool_mode and the loop sends the same request bodies and system prompt as 1.7.0 (same keys, values and JSON types); since 1.11.0 the JSON the SDK writes for the model keeps non-ASCII text literal (see CHANGELOG), so this holds for runs where that JSON holds only code points U+0000-U+007E, in which the 64-level nesting check (see safety) refuses no text, in which no text that goes into a tool-result message holds a surrogate code point and every non-string tool result renders to the same json or repr text as in 1.10.1 (see tools), in which no text that goes into an assistant message's content, a tool call's name or a tool reply's name holds a surrogate code point (see llm), that do not dispatch a tool call while structlog passes the sdk's log keys to stdlib logging as a record's extra with debug enabled for the fifty_agent_sdk.loop logger (see CHANGELOG), in which no value the sdk escapes for a log line held, before the escape, a character that the stream structlog writes that line to cannot encode (see CHANGELOG), and that do not end on an llm or parser error, which since 1.11.0 end with error_fallback_message. on those runs the one event-level change is ThoughtEvent.text on native tool turns, which can now be non-empty (see CHANGELOG). parser= is required, tool_message_role defaults to "tool", and SafetyConfig(native_tools_enabled=True) declares tools natively without changing the parser. that flag still works and is not deprecated, but on its own it leaves a text tool call dispatchable. to migrate, drop parser=, output_format= and native_tools_enabled, and pass tool_mode=ToolMode.NATIVE. the final answer is then plain text in FinalEvent.text, not a json envelope.

skills

instructions the model loads only when a task needs them. a skill is a directory with a SKILL.md file: a short frontmatter naming the skill and saying when to use it, then markdown instructions. the system prompt lists each skill's name and description in a # Skills section, and the model calls a load_skill tool to read a skill's instructions, or a file inside the skill, when it picks one. this follows the open agent skills layout, so skills written for other runtimes load here.

from fifty_agent_sdk import AgentLoop, SkillSet, ToolMode, discover_skills

skills = SkillSet(
    discover_skills("skills")
)  # every skills/<name>/SKILL.md; hidden (dot) directories are skipped

loop = AgentLoop(
    ...,
    tool_mode=ToolMode.NATIVE,
    skills=skills,
)
skills/
└─ pdf-forms/
   ├─ SKILL.md
   └─ reference/
      └─ forms.md
---
name: pdf-forms
description: Fill in PDF forms. Use when the user asks to complete or check a form.
---

# Filling a PDF form

Read reference/forms.md for the field ids, then ...

the frontmatter is read with a strict yaml subset and no yaml dependency: name and description may be plain text, single- or double-quoted on one line, or a | / > block. quote a value yaml could read as something other than text (no, on, 123, 2024-01-01), and a value holding : . a value must start on its key's line (or be a | / > block). other keys (license, metadata, allowed-tools, ...) are kept but not interpreted; Skill.frontmatter holds the raw text if you want to parse it yourself, for example with yaml.safe_load(skill.frontmatter). under those keys, a quoted value or a [ / { collection must close on the line where it opens, because yaml lets those span lines and a following name: line would end up inside them: a multi-line flow list such as allowed-tools: [Bash, is refused (write it as a block list, one - Bash per line). the subset is also stricter than yaml in a few places: a TAB anywhere in the frontmatter, the characters yaml's reader refuses (control characters, U+007F), U+0085, U+2028, U+2029 and U+FEFF are refused, and so is every line starting in column 1 that is not a key: line (a <<: merge key, - item, ? key, %YAML). anything outside the subset raises SkillError naming the file (and the line, when the problem is on one), with a reason code in context["reason"].

a name is 1 to 64 lowercase letters and digits in hyphen-separated runs (pdf-forms), and must equal its directory's name. a description is at most 1024 characters. two skills with one name raise ValueError when the SkillSet is built; a skill named like a registered tool, or a registered tool named load_skill, raise ValueError when the loop is built. SkillSet(..., tool_name="read_skill") renames the load tool. "pdf-forms" in skills tells whether a set holds a skill.

load_skill takes name and an optional file, a path relative to the skill. it returns the instructions, or the file's text. it is an ordinary tool: declared in the tools parameter under NATIVE, listed last in the prompt's tool block under JSON and PROSE, and seen by interventions and hooks like any other call. a call to it counts as a tool call for require_tool_before_final. an unknown name, bad arguments or a refused file come back as a tool error the model can read, never as an exception. the file argument is chosen by the model, so it is confined to the skill's directory: absolute paths, .., :, hidden names and symlinks leading out of the directory are refused, only regular files of at most 256 KiB of utf-8 text are read, and no error text holds a path on your machine. on posix the file is opened by walking from the skill directory with O_NOFOLLOW at every step, so a symlink swapped in after the check is refused too. on windows that walk is not available and the open goes by path, so a swap between the check and the open is not caught there; containment on windows has not been measured.

a skill's body is instructions to your model, so only install skills you trust. allowed-tools is not enforced: the sdk has no permission model.

the set is a snapshot. skill bodies are read when the skills are loaded and the catalog is rendered when the loop is built, while reference files are read when the model asks for one. rebuild the SkillSet and the loop to pick up edited skills. a loaded body is a tool result: it lasts for the run and is never persisted, so on a later turn the model loads the skill again when it needs it. the catalog is part of the loop's own system prompt, which a runner never persists either.

to wire the pieces yourself, without skills=:

skills = SkillSet(discover_skills("skills"))
registry.register(skills.load_tool())
prompts = PromptSections(persona="...", skills=skills.render_catalog())

without skills (or with an empty SkillSet), the loop sends the same request bodies (same keys, values and JSON types), the same system prompt and the same event stream as 1.11.0 (timestamps and minted ids aside).

llm

the llm client. a protocol plus an openai-compatible adapter, so the loop talks to any chat-completions endpoint by changing one base_url. a max_tokens cap is sent as max_completion_tokens for gpt-5.x and o-series models, which reject max_tokens; max_tokens_param= overrides that choice per client.

since 1.11.0, OpenAICompatibleClient writes each surrogate code point (U+D800-U+DFFF), which utf-8 cannot encode, as its six-character \udXXX escape in an assistant message's content, a tool call's name and a tool reply's name when it sends a request. that text is the model's own (its answer echoed back, a tool name) or an assistant message you pass in. before, the request was not sent and the run ended with a raw UnicodeEncodeError and no FinalEvent (measured on openai 2.43.0 and 2.54.0). the loop's tool_invoked debug log line writes a tool name with the same escape: under structlog's default configuration it printed the name to stdout as it was (unless the name holds a space, tab, =, a quote or a line break, which it writes with repr), and on a strict utf-8 stdout (a file or a terminal) it raised there, before the request (measured with structlog 26.1.0). text without a surrogate code point is sent as before. apart from that log line, the client's escape is only in the request, and the model cannot tell it from the same six characters typed: events, ChatResponse and the messages the runner passes to its state store keep the model's text (SqlStateStore and RedisStateStore store such text with the same escape; see state). user and system messages are sent as they are, so a surrogate code point in one still raises UnicodeEncodeError before anything is sent.

for reasoning models, AgentLoop(reasoning_effort="medium") sets reasoning_effort on every request the loop builds, including retries, re-asks, native and streamed turns. it is sent for any model name, with no filtering, so a provider that rejects it fails the run loudly on the first call. the string "none" is a level and is sent; None, the default, leaves it out. AgentLoop(temperature=...) works the same way: omit it and the loop keeps sending 0.0, pass a number to send that, or pass None to leave temperature out of every request. None means leave it out, not use the default. the loop never couples the two, so if your model rejects temperature alongside reasoning_effort (openai documents this for gpt-5.1; the sdk has not verified it), set temperature=None yourself:

loop = AgentLoop(
    ...,
    model="gpt-5.1",
    reasoning_effort="medium",
    temperature=None,
)

to vary either value per request, build a ChatRequest(..., reasoning_effort=..., temperature=...) and call the client directly. a custom LLMClient has to forward reasoning_effort itself.

state

the state stores. where conversation state persists between turns, with branching built in: fork a session, switch between branches, truncate back to an earlier point. MemoryStateStore needs no infrastructure, but it is process-local and non-durable: by default it lazily expires whole sessions when monotonic inactivity reaches 3,600 seconds and retains at most 1,000 sessions using LRU eviction. successful reads refresh inactivity. SqlStateStore and RedisStateStore are durable backends behind the extras.

since 1.11.0, SqlStateStore and RedisStateStore store a message whose content, name or tool_call_id holds a surrogate code point (U+D800-U+DFFF), which utf-8 cannot encode, with each one written as its six-character \udXXX escape. reading the message back returns that escaped text, which cannot be told apart from the same six characters typed; MemoryStateStore keeps the code point. before, persisting such a message raised UnicodeEncodeError (sqlite through aiosqlite) or PydanticSerializationError (redis through fakeredis) instead of StateStoreError; for a final answer the model wrote, that was out of AgentRunner.run() after the FinalEvent, and the answer was not stored (measured on cpython 3.11.15, 3.13.2 and 3.14.3; see CHANGELOG). a message without one is stored as before (for the 15 messages compared, what each store wrote did not change). the rule follows the field, so a user message holding one is stored escaped too, though the shipped client still raises when it sends that message, and the next run then sends two user messages in a row (see llm). tool_calls are stored as before, so RedisStateStore still raises for a surrogate code point in a tool call's name, id or argument value. postgres and a real redis server were not measured.

a runner hands back the store it was built with as runner.state, so the branching calls above are reachable from a runner you already have:

store = MemoryStateStore()
runner = AgentRunner(loop=..., state=store)

runner.state is store  # True — the exact instance, never a copy or a wrapper
branch = await runner.state.fork(session_id, from_sequence=4)

configure either in-memory bound independently when an ephemeral workload needs different limits:

store = MemoryStateStore(ttl_seconds=900, max_sessions=250)

the former unbounded behavior remains available as an explicit opt-in with MemoryStateStore(ttl_seconds=None, max_sessions=None). prefer a durable backend instead when conversation state must survive process restarts.

identity is the point rather than convenience: a second store constructed over the same engine carries its own lock registry, so two writers could interleave on one session. sharing runner.state shares the serialization too.

it is read-only, for correctness and not for style. run() appends the user message, drives the loop, then appends the assistant message — a store swapped in between those appends would split one turn across two backends. assignment raises AttributeError, and mypy rejects it statically. to use a different store, construct another runner; __init__ does no i/o. the declared type is the StateStore protocol, so keep your own concretely-typed reference if you need backend-specific api like SqlStateStore.aclose().

runner.state is the supported way in. _state is private, carries no semver protection, and may be renamed or removed in a patch release.

streaming

a typed event stream the caller consumes while the loop runs. each step in the run surfaces as an event instead of waiting for a final blob.

safety

the caps that bound a run: a max-iteration ceiling on react cycles and a per-tool timeout, plus two fallback answers: fallback_message when a run hits the cap, and error_fallback_message when it ends on an llm or parser error. setting one leaves the other at its default, so set both if you localise them. a loop that can't end is a loop that doesn't ship.

the shipped OpenAICompatibleClient, JsonModeParser and ProseModeParser refuse tool arguments nested deeper than 64 levels (one level per json object or array; brackets inside strings don't count) before decoding them. on a native turn, complete() raises an LLMError whose context["type"] is MalformedResponse and whose context has the key max_tool_args_depth, so no tool of that turn runs and the run ends on that turn with error_fallback_message, with no further request. in json and prose modes the parser first sends a refused text to its recovery pass, which 1.10.1 ran only for text that did not decode, so valid but too-deep text can now parse to a {...} candidate inside it: for example, the json-mode completion [<envelope>, <70 nested arrays>] now parses to that envelope, where 1.10.1 raised a schema error and, with the retry enabled, took the parser retry. a parser error from the refusal takes the parser retry when it is enabled (the default). the check reads brackets, not json, so invalid text can get the same error where 1.10.1 reported invalid json, say x followed by 100 [: on a native turn whenever its brackets open more than 64 levels, and in json and prose modes when its brackets open more than the limit (65 for the whole json-mode envelope) and the parser's recovery pass, which such text goes to first, finds no {...} candidate or one past the limit. a candidate within the limit is parsed as in 1.10.1, so [draft before a valid envelope still parses. read max_tool_args_depth as "the check refused this text", not as proof that the arguments were deeply nested. the limit is fixed: there is no setting. before 1.11.0 such arguments were decoded wherever json.loads could decode them, and a native run could end with a raw RecursionError and no FinalEvent (see CHANGELOG). a custom LLMClient or parser decodes its own arguments and is not checked.

OpenAICompatibleClient classifies a context-window overflow (max_prompt_length, context_length_exceeded or maximum context length in the provider's text, or context_length_exceeded as its error code) on these paths: a 4xx/5xx error that is not a rate limit; a non-streamed http 200 whose body is text that is not json, a bare json string, a json error object ({"error": {...}}), or a body labelled json that the client cannot decode (see below); on a streamed request, a 200 that is not labelled text/event-stream and produces no chunk, an event whose data is not json or is a bare json string, and an error event inside the stream. there it raises an LLMError whose context["type"] is ContextLengthExceeded, and the run ends with an ErrorEvent carrying that context. client-side timeouts, connection errors, rate limits and read failures mid-stream are never classified, and neither are other phrasings of the same failure. a non-streamed 200 with a json content type whose body is not valid utf-8, holds an integer literal over python's digit limit, or nests deeper than json.loads decodes raises an LLMError whose context["type"] is UndecodableProviderBody, quoting the body (measured on openai 2.43.0 and 2.54.0); in 1.10.1 (measured on openai 2.43.0) it escaped as a raw UnicodeDecodeError, ValueError or RecursionError, which the loop does not catch, so the run ended with no ErrorEvent or FinalEvent. on a streamed request the same three failures are UndecodableProviderBody too, quoting the decoder's error instead of the body. to keep those failures apart from a request that cannot be encoded, complete() asks the openai client for the raw response and has the openai client decode it in a separate step, so every non-streamed request carries one more header, X-Stainless-Raw-Response: true (see CHANGELOG). on a non-streamed request a json error object is ErrorEnvelopeProviderBody, quoting the body, where 1.10.1 reported MalformedResponse ("Provider returned no choices.") with its text lost. these provider answers are still not caught (measured on openai 2.43.0): a non-streamed 200 json object with no choices and no truthy error member, such as {"detail": ...} or {"error": null}, is reported as MalformedResponse ("Provider returned no choices.") with its text lost; a body without sse fields (plain text, or a json error object) labelled text/event-stream raises nothing (the stream just looks empty); and a non-streamed 200 not labelled json whose body json.loads cannot decode and whose content type names a codec that is not a text encoding (charset=hex, base64, rot13, zlib, uu; measured on openai 2.43.0 and 2.54.0) still escapes raw as AssertionError or TypeError, as in 1.10.1, so that run ends with no ErrorEvent or FinalEvent. the ErrorEvent's message is diagnostic text that may quote the provider's response; show the FinalEvent to end users.

audit

the audit sinks and observability hooks. they record what the agent did, so a run can be read back after it finishes. hooks that change a run instead of watching it are Interventions, below.

interventions

hooks whose return values the loop honours. Hooks watch a run; Interventions change it, at two points around each tool call. before_tool runs before the call and can deny it or replace its arguments. after_tool runs after the tool returns and can add a note to what the model reads next. they are wired on the loop only, and the runner takes none: a runner's session_id reaches both hooks, and it is None when you drive the loop directly.

from fifty_agent_sdk import (
    AgentLoop,
    DenyToolCall,
    Interventions,
    ObservationEvent,
    ReplaceToolArgs,
)

card_calls: set[str] = set()  # call ids your ui already rendered as cards


def before_tool(session_id, call_id, tool_name, args):
    if tool_name == "delete_record":
        return DenyToolCall(reason="deleting records is disabled in this workspace")
    if args.get("limit", 0) > 50:
        return ReplaceToolArgs(args={**args, "limit": 50})
    return None  # run the call as the model asked


def after_tool(session_id, call_id, tool_name, args, result):
    if call_id in card_calls:
        return "The user can already see these records; answer without listing them."
    return None  # no note


loop = AgentLoop(
    ...,
    interventions=Interventions(before_tool=before_tool, after_tool=after_tool),
)

async for event in runner.run(session_id, message):
    if isinstance(event, ObservationEvent) and shows_as_card(event.result):
        render_card(event.result.output)
        card_calls.add(event.call_id)

after_tool runs once for every call that returned a ToolResult, a success or is_error=True, and never for an unknown tool, a timeout or a denied call. it runs after your code handled that call's event (inline in your async for body; under a runner, after on_tool_end too), so in the example the card set is already up to date. a non-blank string it returns is added after a blank line to the observation the model reads next, the same way in every tool mode; None or a blank string adds nothing. the sdk never writes to the ToolResult, and the events are the ones you would get without the hook, so your own renderer is unaffected. treat result as read-only. args is the hook's own deep copy of the dispatched arguments, taken after the tool returns, so nested values may carry in-place edits the tool made to its own arguments; changing the copy affects nothing else. the note inherits the observation's role, so keep it neutral and factual: in the "user" role it reads as the user's words, and in the "assistant" role as the model's own.

before_tool sees every call, including tool names the model made up. its args is a deep copy of the model's arguments, taken before dispatch, so editing it, even a nested value, changes nothing: return None to run the call as asked, ReplaceToolArgs(args=...) to run it with other arguments, or DenyToolCall(reason=...) to skip it. a denied call still emits ActionEvent and ToolStartedEvent, then a ToolFailedEvent with "Tool call denied: <reason>", and the model reads that reason. like a note, the reason takes the observation's role, so in the "user" role it carries user authority. a replacement is what ActionEvent.args, on_tool_start and the audit payload show; it is never written into the model's own turn. with the shipped parsers and client the copy is complete however deeply the model nests its arguments: they decode them with json.loads, whose dicts and lists are copied without recursion, and strings, numbers, booleans and None are kept as they are. anything else is copied with copy.deepcopy, together with everything inside it. apart from running out of memory, only a value that is not plain json and cannot be copied makes that hook's copy one level deep instead, with a warning. only your own code can put one there: a custom parser or LLMClient, or, for after_tool's copy, a before_tool replacement or any code that stores one into the arguments' nested values, such as a tool or an event consumer writing into ActionEvent.args. change arguments with ReplaceToolArgs, never by editing them in place, and alert on intervention.args_not_copyable.

when a hook fails:

  • after_tool fails soft: if it raises or returns anything other than a string or None, the note is skipped and the run continues.
  • before_tool follows before_tool_fallback. BeforeToolFallback.DENY, the default, fails closed: the call is denied. BeforeToolFallback.ALLOW fails open: the call runs with the model's original arguments. use DENY for guards and for hooks that scope arguments, because a scoping hook (say, one that adds the current tenant) that failed open would run the model's unscoped call. use ALLOW only for advisory hooks, where availability matters more than the check.
  • a returned DenyToolCall is always honoured, whichever fallback you pick.
  • every fallback logs a warning under fifty_agent_sdk.interventions with the call id and the exception or returned type, never the exception's text. under ALLOW that line, with fallback="call_allowed", is the only sign a check was skipped, so alert on it.
  • asyncio.CancelledError propagates from both hooks.
Interventions(before_tool=normalise_dates, before_tool_fallback=BeforeToolFallback.ALLOW)

after_tool cannot replace or remove observation text, change is_error or output, or change an event. before_tool cannot rename a tool; deny instead. to screen mcp isError text, use on_tool_error (see mcp). a note or denial is part of that call's observation for the rest of the run and is never persisted: the next turn sees only the final answer. hooks may be sync or async and are awaited inline, one at a time even within a batch, so keep them fast: a call's own event reaches you before its after_tool runs, but later events wait for it. there is no hook that rewrites the llm request; wrap the LLMClient instead. without interventions, the loop sends the same request bodies (same keys, values and JSON types) and emits the same event stream as 1.9.0 (timestamps and minted ids aside); since 1.11.0 the JSON the SDK writes for the model keeps non-ASCII text literal (see CHANGELOG), so this holds for runs where that JSON holds only code points U+0000-U+007E, in which the 64-level nesting check (see safety) refuses no text, in which no text that goes into a tool-result message holds a surrogate code point and every non-string tool result renders to the same json or repr text as in 1.10.1 (see tools), in which no text that goes into an assistant message's content, a tool call's name or a tool reply's name holds a surrogate code point (see llm), that do not dispatch a tool call while structlog passes the sdk's log keys to stdlib logging as a record's extra with debug enabled for the fifty_agent_sdk.loop logger (see CHANGELOG), in which no value the sdk escapes for a log line held, before the escape, a character that the stream structlog writes that line to cannot encode (see CHANGELOG), and that do not end on an llm or parser error, which since 1.11.0 end with error_fallback_message (see CHANGELOG).

mcp

an mcp client over streamable http, adapted into the same registry the in-proc tools live in. a tools/call that comes back isError=True is a recoverable observation the model can reason about, not a dead run — and on_tool_error is the seam for screening that server-controlled text before the model reads it. an after_tool intervention can only add to that text, so screening stays here.

def screen(message: str, content: list[dict]) -> str:
    # `message` is the sdk's bounded default; `content` is the server's raw
    # error blocks (read-only). return the string the model should see.
    if any("PII" in str(block) for block in content):  # your own predicate
        return "the upstream tool failed"
    return message


client = MCPClient(MCPClientConfig(base_url=...), auth=..., on_tool_error=screen)
provider = MCPProvider(client)
await provider.attach(registry)

the hook may be sync or async, and it only ever fires on a per-call isError result — never on success, never on a transport failure (that still raises MCPError). if it raises, returns a non-string, or returns a blank string, the sdk falls back to its own bounded message and logs a warning; it can never change is_error or output.

Architecture

fifty_agent_sdk  —  module graph (from src/fifty_agent_sdk/, ground-truth imports)

src/fifty_agent_sdk/
├─ ▢ audit
├─ errors
├─ interventions
├─ ▢ llm
├─ loop
├─ ▢ mcp
├─ ▢ observability
├─ ▢ parser
├─ prompts
├─ ▶ runner
├─ safety
├─ ▢ skills
├─ ▢ state
├─ streaming
├─ tool_mode
└─ ▢ tools

depends (→):
   audit → errors
   interventions → observability
   interventions → tools
   llm → errors
   loop → errors
   loop → interventions
   loop → llm
   loop → observability
   loop → parser
   loop → prompts
   loop → safety
   loop → skills
   loop → streaming
   loop → tool_mode
   loop → tools
   mcp → errors
   observability → llm
   parser → errors
   parser → llm
   runner → audit
   runner → errors
   runner → llm
   runner → loop
   runner → observability
   runner → state
   runner → streaming
   skills → errors
   skills → tools
   state → errors
   state → llm
   streaming → tools
   tool_mode → parser
   tool_mode → prompts
   tool_mode → safety
   tools → errors
   tools → llm
   tools → mcp

legend: ▶ entry   ▢ package   name module   → depends

Highlights

  • branching — first-class conversation branching on StateStore: fork, list_branches, switch_branch, branch-scoped get_messages(..., branch_id=...), plus BranchInfo and TRUNK_BRANCH_ID. a session is now a tree of branches with an active head, and append writes to the active branch (the edit-a-message / regenerate model). implemented across memory, SQL, and Redis backends, data-additive and zero-migration: existing sessions read as the trunk branch. breaking for custom StateStore implementations: they must add the new methods.
  • StateStore.truncate_after(session_id, sequence, *, branch_id=None) — a destructive hard-delete of a branch's tail (messages with sequence > N), for redaction, retention, and rollback. only the target branch's own messages are removed (a fork's inherited prefix is never touched), and it is idempotent: a no-op on an unknown session or branch.

editing a turn is a consumer-side fork-then-append, and the original line stays reachable:

# Edit a turn = fork the history before it, switch onto the new branch, then
# append the edited message. `store` is any StateStore; import `ChatMessage`
# from fifty_agent_sdk.
branch = await store.fork(session_id, from_sequence=4)  # keep messages 1..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")  # original line intact

License

MIT.

Metadata

Release files for fifty-agent-sdk 1.12.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fifty-agent-sdk 1.12.0
File Size Uploaded
fifty_agent_sdk-1.12.0.tar.gz 312.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for fifty-agent-sdk 1.12.0
File Interpreter ABI Platform
fifty_agent_sdk-1.12.0-py3-none-any.whl Python 3 none any Details

Total release size: 582.6 kB

Release files / fifty_agent_sdk-1.12.0.tar.gz

Download URL fifty_agent_sdk-1.12.0.tar.gz
Size 312.6 kB
Tags Source
SHA-256 checksum
How to use checksums
858b30ebab26b777dd1df78b17250892030e45cba20e24184aa9b2b50c4e5047
BLAKE2b-256 checksum
How to use checksums
57405137185243a3b50c0b3143fe9692cddab546a3a1bcc5ebb5442866045f17
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / fifty_agent_sdk-1.12.0-py3-none-any.whl

Download URL fifty_agent_sdk-1.12.0-py3-none-any.whl
Size 270.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9da04b3b0385202aa4b1fd4a16ededf59d05940d3468bc096f8c5d2c38141a2a
BLAKE2b-256 checksum
How to use checksums
ef775ec4e49482364a2ab1735eff6d9e475b970a15499b8f689ec30fe71f8a81
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.12.0 This release

2 release files

1.10.1

2 release files

1.10.0

2 release files

1.9.0

2 release files

1.8.0

2 release files

1.7.0

2 release files

1.6.1

2 release files

1.6.0

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.1

2 release files

1.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page