Sample check-up report, generated on 2026-10-21
Check-up of OpenAI Swarm
What was assessed
- Repository: github.com/openai/swarm at 6af0b4ca (commit
6af0b4caf37d). - Agent type: framework. Scope: interaction interactive; horizon session; surface messaging+apis; side_effect reversible; tenancy single-operator-local; verifiable unverifiable; form library/framework.
- Workflows traced: airline example: triage agent with flight, refund and credit tools through run_demo_loop; personal_shopper example: sales and refunds agents on local SQLite through run_demo_loop; support_bot example: user-interface and help-center agents with Qdrant search, ticket and email tools through run_demo_loop.
- Method: Check-up method 3.0 (version 3.0).
- How the conclusions are known: by reading the code and the documents. Nothing was run.
Summary
We read the code of Swarm, OpenAI's small library for building AI assistants that hand a conversation from one assistant to another. We also read its three example apps: an airline helpdesk, a personal shopper and a support bot. Nothing was run, so every conclusion comes from reading the code. You asked us to focus on turn and cost limits, safety of refund and order actions, correct handoffs, and handling of bad tool inputs.
The main problem is that none of these four protections exists in the library itself. There is no limit on how many times the assistant can call the model (F1), and no spending or time budget (F2). A run also never says why it stopped, so a cut-off or filtered reply looks like a normal finish (F3). One malformed tool input ends the whole session instead of being reported back to the model (F4). Refund and order tools run the moment the model asks, with no human approval step (F5). If the model asks for two handoffs at once, the later one silently wins (F8). The personal shopper example cannot start at all, and its agents would ignore their written instructions (F6, F7).
No real harm happens today, because the tools are stubs and the setting is reversible. The risk begins when real refund or order systems are connected.
What is fine: tool calls go through one single dispatch point, a single handoff works and is tested, and the tests use a scripted fake model.
Do first, in this order: F1, F2, F3, F4, F5, F6, F7, F8. The fourteen "fix when" items (F9 to F22) can wait until real systems are connected.
The counts
129 rules considered: 12 met (5 of them a different way), 29 partly, 47 not met, 0 cannot tell, 41 not applicable. 17 optional topics set aside.
Findings: 8 fix now, 0 investigate next, 14 fix when, 0 leave as is.
The findings
- F1 · fix now · No turn limit is in force: max_turns defaults to infinity, run_demo_loop never passes it, and it counts messages rather than model turns
- F2 · fix now · The framework has no built-in run budget (tokens, cost or whole-run time) and never reads model usage, so the required cost limit can only be added by a caller who wraps the client; the default client has none
- F3 · fix now · Every exit returns the same Response with no stop reason, and the model's finish_reason is never read: there is no check for a cut-off or filtered reply, so length and content_filter endings look like a normal finish
- F4 · fix now · Bad tool arguments or any tool exception end the whole run instead of being returned to the model as an error result
- F5 · fix now · The swarm library (swarm/core.py) has no approval or pause for side-effecting tools: every refund, credit or order call runs the moment the model asks, and the only pause mode (execute_tools=False) is used only to inspect tool calls in evals and tests, with no resume or approve helper
- F6 · fix now · personal_shopper cannot start: it imports create_triage_agent from a swarm.agents module that does not exist, so its triage, sales and refunds handoff never runs
- F7 · fix now · personal_shopper sales and refunds agents are given role instructions under a keyword the Agent type does not have, so the instructions are silently dropped and the agents run on 'You are a helpful agent.'
- F8 · fix now · When two transfer calls arrive in one reply both run and the later one silently wins: there is no single decision per batch, and parallel_tool_calls is on by default
- F9 · fix when · personal_shopper order and refund tools: order_item makes a new random purchase on every call, refund_item prints 'Refund initiated' even when no purchase was found, and neither tells the model what happened
- F10 · fix when · Airline refund and credit tools are argument-free stubs gated only by prompt text, with no once-only guard; the model can call initiate_refund and initiate_flight_credits, or either twice
- F11 · fix when · The streaming loop has drifted from the normal loop: saved history labels messages with the starting agent after a handoff, and the loop has no active_agent test
- F12 · fix when · support_bot: handoff is one-way, and send_email/submit_ticket only build a string saying 'Email sent', so the model reports a send that never happened
- F13 · fix when · support_bot retrieval injects only the single best hit, with no relevance cut-off, no exact-term path, no passage id and no URL, so a poor match is passed on as the answer
- F14 · fix when · Airline triage prompt interpolates context raw into the system message, and the shared policy prompt tells the model to call change_intent, a function no agent offers
- F15 · fix when · Provider errors have no handling or fallback: an OpenAI/Qdrant failure raises out of the loop, ends the demo session and loses the in-memory history
- F16 · fix when · Tests and evals do not cover the required behaviours: no test of the turn cap, bad arguments, tool errors or a double handoff, and the airline evals use the live model with no baseline gate
- F17 · fix when · Observability is a stdout debug print only: off unless debug=True, no ids or durations, exceptions are not logged and no run log is kept
- F18 · fix when · Tool schemas are lossy and unvalidated: unannotated arguments all become 'string', there are no per-parameter descriptions, duplicate tool names silently collapse, and tool calls have no timeout
- F19 · fix when · A model_override of '' or None is expected to select that model but does nothing: the agent's own model is used silently, and model choice is the only setting with any precedence order
- F20 · fix when · History is an untyped list of provider-shaped dicts: tool-call pairing is kept only by careful code, with no type or validator, and extra fields (sender) are sent back to the API
- F21 · fix when · The in-memory history is the only record and tool results are taken as returned: nothing re-reads the world after a refund or order, so a stub or wrong result is believed
- F22 · fix when · Streamed deltas are passed to the caller as received, with no redaction and no cancel or consumer-lost policy
F1. No turn limit is in force: max_turns defaults to infinity, run_demo_loop never passes it, and it counts messages rather than model turns (fix now)
- At stake: The brief requires turn and cost limits: one user action must not be able to spend an unbounded number of model calls, including across handoffs.
- Failure path: run_demo_loop calls client.run(...) without max_turns, so the default float('inf') applies. The loop ends only when the model returns no tool calls. Two agents that keep handing back to each other (transfer_to_triage and a transfer tool) or a model that keeps calling tools spend API calls until the model stops. The same infinite default is in run_and_stream. The cap that does exist compares len(history)-init_len, i.e. messages (one assistant message plus one per tool call), not model turns, so a value set by a caller means fewer turns than it says.
- Consequence: Unbounded spend and a hung interactive session on any runaway tool or handoff loop; the whole history is re-sent on every call so each pass costs more than the last. No oscillation or repeat detection exists to catch it earlier.
- Evidence:
swarm/core.py:239,max_turns: int = float("inf"),;swarm/core.py:257,while len(history) - init_len < max_turns and active_agent:;swarm/repl/repl.py:73,response = client.run( - Owner of the problem: swarm/core.py Swarm.run and run_and_stream; swarm/repl/repl.py run_demo_loop
- Smallest change: Give max_turns a finite default (a documented number of model calls), count model calls in a loop-local counter instead of messages, apply the same counter in run and run_and_stream (one shared helper), and pass an explicit value from run_demo_loop. Optionally add a repeat/oscillation check on identical consecutive tool calls or A->B->A transfers that stops with a named reason (see F3).
- Could break: Callers that rely on very long tool chains will stop earlier; eval_utils passes max_turns=1 and its meaning changes if the count moves from messages to model calls.
- Acceptance: With a scripted mock client that always returns a transfer_to_triage / transfer_to_x tool call, Swarm.run(agent, messages) with no max_turns returns after the default number of model calls (mock client call count equals the default), and run_demo_loop does the same on a scripted input.
- Known by: code read. Confidence: defect high, remedy high. Challenge: upheld.
F2. The framework has no built-in run budget (tokens, cost or whole-run time) and never reads model usage, so the required cost limit can only be added by a caller who wraps the client; the default client has none (fix now)
- At stake: The brief requires cost limits: the framework should offer a bound on the spend of one user action, fed by one usage counter that also feeds display. A caller can inject a client (Swarm(client=...)) that counts usage, but the framework itself provides no budget and reports no usage.
- Failure path: The only limit is max_turns, off by default (float('inf')). It counts messages added to history, including tool results, not model calls. Each completion is reduced to its first message (usage dropped), and Response carries only messages, agent and context_variables. The whole history is re-sent each call with no compaction. support_bot adds an embeddings call per query_docs outside the loop. Swarm sets no timeout of its own: with no client given it builds OpenAI() with the SDK's default timeout and retries. A caller can inject a client whose chat.completions.create counts usage and raises past a budget, but a raise loses the whole run (Response is built only at the end of each loop), and streaming usage never arrives because create_params has no stream_options. The real gap is no built-in budget or deadline over the whole run, and no usage on the result.
- Consequence: With the default client a long session or runaway loop has no cost ceiling and the operator cannot see what was spent. Count limits alone are not a money bound, since history growth makes later calls dearer. A caller-side wrapper can enforce a bound but can only abort by exception, discarding the work done.
- Evidence:
swarm/core.py:268,message = completion.choices[0].message;swarm/core.py:239,max_turns: int = float("inf"),;swarm/core.py:257,while len(history) - init_len < max_turns and active_agent:;swarm/core.py:154,while len(history) - init_len < max_turns:;swarm/core.py:27,def __init__(self, client=None):;swarm/core.py:29,client = OpenAI();swarm/core.py:63,"stream": stream,;swarm/core.py:182,delta = json.loads(chunk.choices[0].delta.json());swarm/types.py:23,class Response(BaseModel):;swarm/types.py:26,context_variables: dict = {} - Owner of the problem: swarm/core.py Swarm.run and run_and_stream; swarm/types.py Response
- Smallest change: Read completion.usage after every model call and add totals and call count to Response. For streaming, add stream_options={"include_usage": True} to create_params (core.py 58-64) when stream is True, since usage only arrives when asked for, and skip chunks with empty choices at line 182 (the usage-only final chunk has none). Add optional max_tokens_total / max_seconds, checked in one shared helper called from both loop conditions (core.py 154 and 257) together with the turn limit, so the two loops cannot drift apart (run_and_stream's condition today lacks the 'and active_agent' test). On a trip, break out of the loop and return a partial Response with a named stop reason (F3); do not raise, because Response is built only at the end of each loop and a raise would lose the work done.
- Could break: Adds Response fields (additive). stream_options is sent to the provider only when stream=True; a provider or mock that rejects it needs a switch. The usage-only final chunk has empty choices, so line 182 (chunk.choices[0]) would fail without the guard.
- Acceptance: With a mock client returning usage numbers, Swarm.run(..., max_tokens_total=N) stops once the total passes N, returns the partial Response with a budget stop reason (no exception) and Response.usage equals the sum; the same for run_and_stream with a usage-only final chunk (no IndexError) and with create called with stream_options include_usage; a failed call still counts the calls made.
- Known by: code read. Confidence: defect high, remedy medium. Challenge: upheld.
F5. The swarm library (swarm/core.py) has no approval or pause for side-effecting tools: every refund, credit or order call runs the moment the model asks, and the only pause mode (execute_tools=False) is used only to inspect tool calls in evals and tests, with no resume or approve helper (fix now)
- At stake: The brief requires tool-call safety on side-effecting tools (refunds, orders): a risky call should be paused for a person, bound to the exact call, before it runs. execute_tools=False is the only hook in the library; run_demo_loop and the shipped workflows never use it as an approval step.
- Failure path: A tool is a bare callable (AgentFunction), so the library cannot tell initiate_refund or order_item from a lookup. handle_tool_calls calls any tool at once; run and run_and_stream both go through it (run hands stream=True to run_and_stream), so a gate there covers both paths and handoffs. execute_tools=False (README: 'interrupt execution and immediately returns tool_calls message') is used only in examples/triage_agent/evals.py, examples/weather_agent/evals.py and tests/test_core.py to inspect calls. It returns the assistant message with its tool_calls unanswered; there is no resume or approve helper, so the caller has to build the tool replies by hand (by tool_call_id) and call run again, with no documented contract. A handed-off agent gets its full tool list with no approval policy inherited. There is no once-only guard and no argument check; the only guard on the airline refund is prompt text, and the triage example's refund guard is also prompt text only (examples/triage_agent/agents.py: 'Ask for user confirmation before processing the refund.'). Outside the library, examples/customer_service_streaming/src/swarm/engines/local_engine.py has a per-tool human_input flag and a console prompt ('Type yes to execute tool'); that is a separate engine and does not cover swarm/core.py. Its skip path adds an assistant message, not a paired tool reply.
- Consequence: Once a real refund or order backend replaces the stubs, a hallucinated or prompt-injected call takes effect immediately and can repeat. The envelope is reversible and the shipped tools are stubs, so no harm happens today, but the safeguard people may assume (approval) is absent from the library.
- Evidence:
swarm/core.py:122,raw_result = function_map[name](**args);swarm/core.py:275,if not message.tool_calls or not execute_tools:;swarm/core.py:198,if not message["tool_calls"] or not execute_tools:;swarm/types.py:11,AgentFunction = Callable[[], Union[str, "Agent", dict]];examples/airline/configs/tools.py:14,status = "Refund initiated";examples/triage_agent/evals.py:32,execute_tools=False,;examples/triage_agent/agents.py:5,Ask for user confirmation before processing the refund.;examples/customer_service_streaming/src/swarm/engines/local_engine.py:177,human_input_flag = next((tool.human_input for tool in assistant.tools if tool.function.name == step['tool']), False);examples/customer_service_streaming/src/swarm/engines/local_engine.py:184,user_confirmation = input(;README.md:135,interrupt execution and immediately returns 'tool_calls' message - Owner of the problem: swarm/core.py handle_tool_calls and Swarm.run; swarm/types.py Agent/AgentFunction
- Smallest change: Let an Agent list tools that need approval (or let a function carry a marker); the in-repo precedent is the per-tool human_input flag and console prompt in examples/customer_service_streaming/src/swarm/engines/local_engine.py (lines 177-184). Add an optional approve(tool_name, args) callback to Swarm.run; before line 122 call it for marked tools, and on a denial append a paired tool message ('Denied by operator') with the same tool_call_id instead of running (not an unpaired assistant message as the legacy engine does). Have run_demo_loop prompt on the console for marked tools. Keep execute_tools=False but add a resume/approve helper or document the contract (caller answers each call by id, then calls run again).
- Could break: Existing callers see no change unless they mark tools; a blocking callback makes streaming runs wait. Denied-call messages become part of history.
- Acceptance: With a mock client asking for initiate_refund and an approve callback returning False, Swarm.run does not call the function, the history holds a tool message for that tool_call_id saying it was denied, and the model gets a follow-up completion; with True the function runs once. The same holds with stream=True (run_and_stream). In a batch of parallel tool calls where one is denied, every tool_call_id still gets a paired reply.
- Known by: code read. Confidence: defect high, remedy medium. Challenge: upheld.
In the app
This page shows part of the report. The full report and the fix plan are delivered in the app.