Skip to main content
المدونة

Zalt Blog

Deep Dives into AI Engineering

AT SCALE

Why One Agent Must Serve One Run

By محمود الزلط
Code Cracking
15m read
<

Why must one agent serve one run? For engineers designing agent systems, this simple boundary offers a clearer way to think about ownership and responsibility.

/>
Why One Agent Must Serve One Run - Featured blog post image
Mahmoud Zalt

1:1 Mentor

Are you a software engineer moving into AI?

Let's have a call. I'll help you modernize your skills and learn the tools, systems, and architecture behind reliable AI products. One session or ongoing.

Hire AI Employees

Hire AI Employees that work 24/7. No code.

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 executor
agents.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 holderPurposeRisk when shared
self.stateValues available to tools and codeOne run can overwrite another run’s values
AgentMemoryTasks, plans, actions, and observationsHistories can reset or interleave
interrupt_switchStops the active loopAn interrupt has no per-run identity
step_numberTracks bounded-loop progressRuns can corrupt each other’s progress
Executor stateVariables and execution resourcesCode 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.

MetricQuestionResponse
agent_run_duration_secondsHow long is the user-visible run?Watch p95 against the request deadline.
agent_steps_per_runIs the agent solving work efficiently or looping?Track p50, p95, and max_steps exhaustion.
model_tokens_totalIs 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.

Full Source Code

Direct source from the upstream repository. Preview it inline or open it on GitHub.

heads/main/src/smolagents/agents.py

huggingface/smolagents • refs

Read Code on GitHub

Thanks for reading! I hope this was useful. If you have questions or thoughts, feel free to reach out.

Content Creation Process: This article was generated via a semi-automated workflow using AI tools. I prepared the strategic framework, including specific prompts and data sources. From there, the automation system conducted the research, analysis, and writing. The content passed through automated verification steps before being finalized and published without manual intervention.

Mahmoud Zalt

About the Author

I’m Zalt, a technologist with 16+ years of experience, passionate about designing and building AI systems that move us closer to a world where machines handle everything and humans reclaim wonder.

Let's connect if you're working on interesting AI projects, looking for technical advice or want to discuss anything.

Support this content

Share this article