A reusable agent loop is also a mutable run container. That fact determines its safe concurrency and deployment model.
We’re examining how Hugging Face’s agents.py coordinates an agent run. smolagents connects models, memory, tools, and code executors into agents that plan, act, observe, and return an answer. Its central MultiStepAgent class matters because it owns that lifecycle—and, less visibly, the mutable state created by it.
An agent can look like a stateless chatbot at the API boundary. Inside, it is a state machine: it records a task, plans, invokes a model, executes work, stores observations, and decides whether to continue. I’m Mahmoud Zalt, an AI solutions architect, and we’ll trace why that design leads to one operational rule: one agent instance should serve one run unless sharing is explicitly synchronized. We’ll first separate orchestration from execution, then follow state through parallel tools and into deployment decisions.
The coordinator owns the lifecycle
MultiStepAgent is not merely a model wrapper. A caller gives run() a task; the agent records it, optionally plans, requests an action from a model, executes a tool or code, stores the observation, and repeats until it receives a final answer or reaches max_steps. This is a ReAct loop: reasoning about the current situation, acting, then using the observation to select the next action.
agents.py
├── MultiStepAgent.run()
│ └── _run_stream()
│ ├── _generate_planning_step()
│ └── _step_stream()
├── ToolCallingAgent._step_stream()
│ └── process_tool_calls() → BaseTool / managed agent
└── CodeAgent._step_stream()
└── PythonExecutor(code_action) → local or remote executoragents.py coordinates models, memory, tools, monitoring, and execution backends.The boundary is deliberate. Model owns model transport; AgentMemory owns historical records; PythonExecutor implementations own code isolation. MultiStepAgent owns sequencing and policy. Its structured records—PlanningStep, ActionStep, ToolCall, and FinalAnswerStep—make callbacks, replay, testing, and richer RunResult values possible.
The class uses the Template Method pattern: _run_stream() retains planning, limits, interruption, error recording, memory updates, callbacks, and finalization, while concrete agents provide initialize_system_prompt() and _step_stream(). ToolCallingAgent dispatches structured tool calls; CodeAgent sends Python to a configured executor. New action strategies therefore inherit the lifecycle rather than reimplementing it.
This centralized lifecycle also explains choices such as rendering prompts with Jinja StrictUndefined: a missing template value fails near its source instead of quietly altering later model behavior. Likewise, from_dict() reconstructs managed agents through the explicit AGENT_REGISTRY, which permits ToolCallingAgent and CodeAgent rather than importing an arbitrary serialized class name.
Parallel work needs one commit owner
The shared lifecycle remains sequential at the run level, but a tool-calling step may execute several requested tools concurrently. ToolCallingAgent submits calls to a ThreadPoolExecutor and copies the current context into each worker, preserving context-local information such as tracing state.
with ThreadPoolExecutor(self.max_tool_threads) as executor:
futures = [executor.submit(copy_context().run, process_single_tool_call, call)
for call in parallel_calls.values()]
for future in as_completed(futures):
output = future.result()
outputs[output.id] = output
yield output
memory_step.tool_calls = [parallel_calls[k] for k in sorted(parallel_calls)]This code preserves two different kinds of order. The live stream follows completion order, so a fast tool becomes visible without waiting for a slow one. Durable memory sorts call IDs, so later prompts, tests, and replays receive a predictable history despite timing variation.
That distinction is useful, but it does not make shared state safe. Worker threads can invoke arbitrary tools, touch shared clients, quotas, files, or external systems, and may store AgentImage or AgentAudio results in self.state. Parallelism reduces wall-clock time only when the tools and every shared dependency tolerate concurrent use.
Mutable state defines the run boundary
The decisive evidence is what run() writes onto the agent itself:
self.task = task
self.interrupt_switch = False
if additional_args:
self.state.update(additional_args)
self.memory.system_prompt = SystemPromptStep(system_prompt=self.system_prompt)
if reset:
self.memory.reset()
self.monitor.reset()A run mutates self.task, self.state, self.memory, self.monitor, self.interrupt_switch, and later self.step_number. A CodeAgent can also carry executor state. These are not immutable configuration fields; they are the identity and history of an active execution.
| State holder | Purpose | Risk when shared |
|---|---|---|
self.state | Values available to tools and code | One run can overwrite another run’s values |
AgentMemory | Tasks, plans, actions, and observations | Histories can reset or interleave |
interrupt_switch | Stops the active loop | An interrupt has no per-run identity |
step_number | Tracks bounded-loop progress | Runs can corrupt each other’s progress |
| Executor state | Variables and execution resources | Code can observe shared or stale state |
If two requests call run() concurrently on one instance, either can replace the task, reset memory, alter variables, or interrupt the other. The safe default is one agent instance per request. A shared instance requires an explicit synchronization boundary, with the throughput and cancellation semantics that choice implies.
additional_args deepen the boundary problem: they enter self.state and are interpolated into the task with str(additional_args). They can become visible to the model, memory, and task logging. Sensitive values need redaction before AgentLogger receives task text, arguments, observations, or outputs.
Operate and harden the boundary
Once ownership is explicit, a few implementation choices become straightforward. In non-streaming mode, run() need not convert the entire event generator to a list merely to inspect its final item. Retaining only the latest emitted step reduces transient event retention from O(S) to O(1), where S is the event count. AgentMemory still grows, but the implementation stops paying for the stream twice.
Likewise, use explicit exceptions for caller-controlled contracts, such as validating that every configured tool is a BaseTool. Python can remove assert statements under optimization, so assertions belong to implementation-controlled invariants, not production validation.
One instance per request enables horizontal scaling, but it does not remove capacity limits. Model calls, tools, remote executors, and Hub operations are network-dependent. Concrete model, tool, and executor adapters should own timeouts, retries, rate limits, and circuit breakers; the application should impose an outer deadline for the complete request.
| Metric | Question | Response |
|---|---|---|
agent_run_duration_seconds | How long is the user-visible run? | Watch p95 against the request deadline. |
agent_steps_per_run | Is the agent solving work efficiently or looping? | Track p50, p95, and max_steps exhaustion. |
model_tokens_total | Is history expanding later model calls? | Set provider-specific budgets and monitor growth per step. |
write_memory_to_messages() rebuilds model input from accumulated memory, so context growth raises token count and latency on later steps. This compounds with the ReAct loop’s sequential dependency: step two waits for step one’s model call and action.
Security follows the same ownership-first logic. from_hub() requires trust_remote_code=True, and AGENT_REGISTRY limits agent-class reconstruction, but neither is a sandbox. Downloaded tools may execute locally, while CodeAgent deliberately runs model-generated Python through a local or remote executor. For untrusted tasks, use isolated executor infrastructure with explicit CPU, memory, filesystem, egress, process, and timeout limits, and ensure cleanup() runs on success, failure, and cancellation.
Design ownership first
The primary lesson is simple: a reusable agent loop is safe to deploy only when we treat its mutable agent instance as the owner of a single run. MultiStepAgent is extensible precisely because it centralizes lifecycle policy while allowing tool calls and code execution to vary. That same centralization means task text, memory, interrupts, progress, monitoring, and executor state are coupled to the instance.
- Create one agent per request unless you deliberately synchronize a shared instance.
- Keep concurrent workers isolated and let the coordinator commit shared state in a deterministic order.
- Bound and observe the run: cap steps, tool fan-out, executor resources, and context growth while measuring duration, steps, and tokens.
When reviewing an agent framework, begin by tracing every mutable field and every thread or process boundary—not by reading the prompt. Once ownership is visible, the correct concurrency model, scaling plan, and isolation requirements follow from it.







