Skip to main content

🥣 AgentSoup

Mix prompts, models, and logic — cook up LLM-powered functions with ease.

Write a Python function, return the prompt, get back a typed result. AgentSoup turns functions into LLM calls, agents with tools, and trackable pipelines — all with the same tiny interface.

pip install agentsoup

Works with any provider litellm supports (OpenAI, Anthropic, Gemini, …) — set the matching API key env var and pass the model name.

The idea: return the prompt

from agentsoup import llm
from pydantic import BaseModel

class Book(BaseModel):
    title: str
    author: str

@llm(model="gpt-4.1")
def recommend(topic: str) -> Book:
    return f"Recommend one great book about {topic}"

book = recommend("octopuses")   # Book(title='...', author='...')

The function body builds the prompt; the return type hint picks the output: -> str (or none) returns text, a pydantic model returns a parsed instance, and -> CompleteResponse[T] returns (parsed, raw_completion).

Transient provider errors (rate limits, timeouts, connection/5xx) are retried automatically with exponential backoff and jitter — retries=3 by default, tune per function (retries=5) or per call site via with_options; retries=0 disables. A structured response that fails validation is sent back to the model with the error so it can fix it (repair=1 by default; repair=0 disables).

Set a default system prompt on the decorator with system= — it's prepended to every call, and overridden whenever the return value includes its own system(...) message (or via with_options(system=...)):

@llm(model="gpt-4.1", system="You are a terse librarian.")
def recommend(topic: str) -> Book:
    return f"Recommend one great book about {topic}"

Any decorated function can be re-tuned without re-defining it — with_options returns a copy with merged parameters (any litellm kwarg; on agents also tools, mcp_servers, max_turns):

cheap = recommend.with_options(model="gpt-4.1-mini", temperature=0)
cheap("octopuses")

Multimodal: just return the pieces

Strings, pathlib.Paths, and media URLs mix freely in a returned tuple — each becomes the right content part automatically (images, video, audio, PDFs; MIME type detected from the file). Local files must be Path objects — bare strings are always sent as text, never sniffed for file paths:

from pathlib import Path
from agentsoup import llm, system

@llm(model="gemini/gemini-2.5-flash")
def analyze(img: str, clip: str) -> str:
    return "Compare this photo and video:", Path(img), Path(clip)

@llm(model="gpt-4.1")
def summarize_pdf(url: str) -> str:
    return system("You are terse."), "Summarize:", url   # e.g. https://x.com/doc.pdf

Explicit part/message types (Text, Image, Video, Audio, File, system(...), user(...), assistant(...)) are there when you want control — each media class takes a local path or URL.

Agents: the same decorator, plus tools

There is exactly one decorator — @agent is an alias of @llm. Add tools= and the model can call them in a loop until it has an answer. A tool is any typed Python function with a docstring:

from agentsoup import agent

def get_weather(city: str, units: str = "c") -> str:
    """Look up current weather for a city."""
    ...

@llm(model="gpt-4.1-mini")
def summarize(text: str) -> str:
    """Summarize text in two sentences."""
    return f"Summarize: {text}"

@agent(model="gpt-4.1", tools=[get_weather, summarize], max_turns=10)
def assistant(question: str) -> str:
    return question

Note summarize: @llm and @agent functions are themselves valid tools, so agents can delegate to sub-agents with zero extra syntax. Tool schemas are generated from signatures and type hints — the docstring becomes the tool description, and Annotated[str, Field(description="...")] documents individual parameters. Tool errors are fed back to the model instead of crashing.

Fan out with .map()

Every decorated function has .map(items) — one call per item, all in parallel, results in order. Because the function body is plain Python that runs before the LLM call, fan-out-and-summarize fits in one function:

@llm(model="gpt-4.1-mini")
def summarize(chunk: str) -> str:
    return "Summarize:", chunk

@llm(model="gpt-4.1")
def report(chunks) -> str:
    return "Combine these summaries:", summarize.map(chunks)   # parallel fan-out, then one final call

.map() works the same on agents with tools (MCP sessions are opened once and shared across the whole map), and extra kwargs are forwarded to every call: summarize.map(chunks, style="terse"). Control it with max_workers= (cap concurrency for rate limits) and return_exceptions=True (a failed item yields its exception instead of cancelling the rest).

MCP servers

MCP servers go in the same tools= list — as a URL string, a command string, or a config object when you need headers/env:

from agentsoup import agent, HTTPServer

@agent(model="gpt-4.1", tools=[
    get_weather,                                              # python function
    "npx -y @modelcontextprotocol/server-filesystem ./docs",  # stdio MCP server
    "https://mcp.example.com/mcp",                            # HTTP MCP server
    HTTPServer("https://mcp.linear.app/mcp", headers={"Authorization": "Bearer ..."}),
])
def helper(task: str) -> str:
    return "Complete this task using the available tools:", task

The model sees MCP tools and Python tools identically; a name collision with one of your Python tools is resolved by prefixing the MCP tool with its server label. Sessions connect when the function is called and tear down when it returns (one shared session for a whole .map). Set timeout= on a server config for long-running tools.

Pipelines and subagents are just functions

There is no pipeline framework. A pipeline is a function that calls other functions; a subagent is an @llm function called by another (directly in the body, or handed to the model via tools=); parallelism is .map():

def write_article(topic):                       # the whole pipeline
    o = outline(topic)
    sections = draft_section.map(plan(o))       # fan out
    return edit(sections)                       # fan in

Chat is a list

No session or context objects — history is a plain list you own, and you never need to build messages by hand. Splat the history into the returned tuple (the trailing loose value becomes the new user message), and take the next history from resp.messages — the call's full transcript, including reasoning, tool calls, and tool results:

from agentsoup import agent, CompleteResponse

@agent(model="gpt-4.1", tools=[search], system="You are a research assistant.")
def turn(history, msg: str) -> CompleteResponse[str]:
    return *history, msg

history = []
while (msg := input("> ")):
    resp = turn(history, msg)
    print(resp.parsed_response)
    history = resp.messages     # next turn continues with everything the model saw and did

The explicit message helpers (user(...), assistant(...), system(...)) exist for when you want manual control — few-shot examples, editing or compacting history, or a lighter history that keeps only [user(msg), assistant(answer)] pairs and drops tool traffic.

Windowing is history[-20:]; branching a conversation is copying the list (turn.map over variants works too); persistence is json.dumps([m.to_openai_format() for m in history]) and back via Message.from_openai_format. Structured outputs mid-conversation just work — assistant(some_pydantic_obj) serializes it as JSON.

Validation is just a loop

There is no requirements/verifier framework either — a judge is just another @llm function, and validate-and-repair is a for loop:

class Verdict(BaseModel):
    passed: bool
    feedback: str

@llm(model="gpt-4.1-mini")
def judge(draft: str, rules: str) -> Verdict:
    return f"Check this draft against the rules: {rules}", draft

def reliable_write(topic, rules, budget=3):
    feedback = ""
    for _ in range(budget):
        draft = write(topic, feedback)
        verdict = judge(draft, rules)
        if verdict.passed:
            return draft
        feedback = verdict.feedback
    return draft

Deterministic checks are an if; a judge panel is judge.map(...); best-of-N is write.map([topic] * 5) plus picking the winner; escalation is write.with_options(model=...) on the last attempt.

Tracking: a sink is just a function

Wrap any code in track() and every @llm/@agent call inside — nested, parallel, agent-in-agent — emits a plain JSON-serializable event dict to your sinks. A sink is any callable taking one dict:

from agentsoup import track

with track() as run_id:                        # default sink: track.state_file(".agentsoup/runs")
    write_article("octopus intelligence")

with track(track.state_file("runs/"),          # combine any sinks
           track.webhook("https://example.com/hook", headers={"Authorization": "Bearer ..."}),
           track.otel()):                      # pip install agentsoup[otel]
    write_article(topic)

with track(print): ...                         # instant debugger
events = []
with track(events.append): ...                 # capture for tests/analysis

Events: run_started / call_started / call_finished / call_failed / run_finished (or run_failed), each carrying run_id, time, and for calls: call_id, name, duration_s, output or error, and usage (LLM calls, prompt/completion tokens, estimated USD cost).

The shipped sinks hang off track itself: track.state_file(dir) maintains <dir>/<run_id>.json — a full snapshot rewritten atomically after every event (with run-level usage totals), so another process can watch live with json.loads(path.read_text()); track.webhook(url, headers=) POSTs each event (terminal events block so process exit can't drop them); track.otel() opens one OpenTelemetry span per call with usage/cost attributes, feeding whatever tracer provider you've configured.

The library owns the correctness so your sinks can be naive: sink calls are serialized under a lock (safe under .map fan-out), the active tracker is context-local (concurrent runs in different threads stay isolated; nested blocks restore the outer), and a sink that raises is disabled with a logged warning — tracking can never fail the run.

API summary

@llm(model, system=, tools=, max_turns=, retries=, repair=, **litellm_kwargs) the one decorator: return value → prompt, return hint → output type, tools= → agent loop
@agent alias of @llm — reads better when tools are involved
tools=[...] functions, @llm functions, Tool objects, MCP servers (config, URL, or command string) — all in one list
StdioServer / HTTPServer MCP server configs, for when you need headers/env
track(*sinks, run_id=) emit every call in a block as event dicts; a sink is any callable
track.state_file(dir) / track.webhook(url, headers=) / track.otel() shipped sinks: live JSON snapshot, HTTP push, OpenTelemetry spans
Text, Image, Video, Audio, File, system/user/assistant explicit content when you want it
CompleteResponse[T] also get the raw completion + full transcript (.messages) for full-context continuation
fn.map(items, **kwargs) call once per item, in parallel; ordered list of results
fn.with_options(**overrides) copy of a decorated function with changed parameters

Development

pip install -e ".[dev]"
pytest

MIT licensed.

Release files for agentsoup 0.2.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 agentsoup 0.2.0
File Size Uploaded
agentsoup-0.2.0.tar.gz 33.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for agentsoup 0.2.0
File Interpreter ABI Platform
agentsoup-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 53.1 kB

Release files / agentsoup-0.2.0.tar.gz

Download URL agentsoup-0.2.0.tar.gz
Size 33.3 kB
Tags Source
SHA-256 checksum
How to use checksums
3c53074341a371eb2ade8038887bccbbc2608bccf3a308e3bfed3168423c02c1
BLAKE2b-256 checksum
How to use checksums
96b755f33cb6274d0bcac8267cefeea539976b1b66c6259208f42c664bcebbfc
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 Aug 13, 2026.

Transparency log

Release files / agentsoup-0.2.0-py3-none-any.whl

Download URL agentsoup-0.2.0-py3-none-any.whl
Size 19.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
468b93f5f5cd789aee236bc8d01918065b736df9359bcf591f2a8672476d6ab5
BLAKE2b-256 checksum
How to use checksums
b1f3265ccc4020042f407b62a388094b533631b97a354617c3e428f8c3ffbd88
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 Aug 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.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