Metadata-Version: 2.4
Name: fifty-agent-sdk
Version: 1.12.0
Summary: Production-grade reusable agent loop SDK — custom ReACT, JSON-mode tool calls, MCP client, pluggable LLM/state/tools.
Author: fifty.dev
Maintainer: fifty.dev
License: MIT
Project-URL: Homepage, https://github.com/fiftynotai/fifty-agent-sdk
Project-URL: Repository, https://github.com/fiftynotai/fifty-agent-sdk
Project-URL: Issues, https://github.com/fiftynotai/fifty-agent-sdk/issues
Project-URL: Changelog, https://github.com/fiftynotai/fifty-agent-sdk/blob/main/CHANGELOG.md
Keywords: agent,llm,react,mcp,tool-calling,openai,ai,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pydantic>=2.5.0
Requires-Dist: typing-extensions>=4.6.1
Requires-Dist: httpx>=0.26.0
Requires-Dist: structlog>=24.1.0
Requires-Dist: openai<3.0.0,>=1.30.0
Requires-Dist: mcp<2.0.0,>=1.27.0
Provides-Extra: sql
Requires-Dist: sqlalchemy[asyncio]>=2.0.0; extra == "sql"
Provides-Extra: redis
Requires-Dist: redis>=5.0.0; extra == "redis"
Provides-Extra: dev
Requires-Dist: pytest<10.0.0,>=7.4.0; extra == "dev"
Requires-Dist: pytest-asyncio<2.0.0,>=0.23.0; extra == "dev"
Requires-Dist: pytest-cov<8.0.0,>=4.1.0; extra == "dev"
Requires-Dist: pytest-httpx<0.37.0,>=0.30.0; extra == "dev"
Requires-Dist: ruff==0.16.0; extra == "dev"
Requires-Dist: mypy==2.3.0; extra == "dev"
Requires-Dist: aiosqlite<0.23.0,>=0.19.0; extra == "dev"
Requires-Dist: fakeredis<3.0.0,>=2.20.0; extra == "dev"
Requires-Dist: pyyaml<7.0.0,>=6.0.1; extra == "dev"
Dynamic: license-file

<p align="center">
  <img src=".github/banner.png" alt="fifty-agent-sdk — a reusable agent loop for python." width="100%">
</p>

# fifty-agent-sdk

[![PyPI](https://img.shields.io/pypi/v/fifty-agent-sdk)](https://pypi.org/project/fifty-agent-sdk/)
[![Python](https://img.shields.io/pypi/pyversions/fifty-agent-sdk)](https://pypi.org/project/fifty-agent-sdk/)
[![CI](https://github.com/fiftynotai/fifty-agent-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/fiftynotai/fifty-agent-sdk/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

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.

```python
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:

```python
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.

```python
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
```

```markdown
---
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=`:

```python
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:

```python
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:

```python
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:

```python
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.

```python
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.

```python
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.

```python
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:

```python
# 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
```

## Links

- [Homepage](https://github.com/fiftynotai/fifty-agent-sdk)
- [Repository](https://github.com/fiftynotai/fifty-agent-sdk)
- [Issues](https://github.com/fiftynotai/fifty-agent-sdk/issues)
- [Changelog](https://github.com/fiftynotai/fifty-agent-sdk/blob/main/CHANGELOG.md)

## License

MIT.
