From 2b21feb82afafaf96d69ebf900e880166b153731 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Mon, 10 Aug 2026 09:03:09 -0700 Subject: [PATCH 1/4] fix(python): include constructor tools in agent hook startup --- .../core/agent_framework/_agent_hooks.py | 8 +++++++- .../packages/core/tests/core/test_agent_hooks.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 669cdb262a..b2c7a3c216 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -859,7 +859,13 @@ def _tool_names(context: AgentContext) -> list[str]: """Project the registered tool names for ``agent_startup`` (spec ``tools_registered``).""" from ._tools import _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage] - tools: Any = context.tools if context.tools is not None else getattr(context.agent, "tools", None) + if context.tools is not None: + tools: Any = context.tools + else: + tools = getattr(context.agent, "tools", None) + default_options = getattr(context.agent, "default_options", None) + if tools is None and isinstance(default_options, Mapping): + tools = default_options.get("tools") if tools is None: return [] try: diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index 347d7bae4f..de347f3836 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -275,6 +275,22 @@ async def test_full_tool_run_emits_complete_ordered_session(chat_client_base: Mo assert pre_tool["tool_call"]["id"] == "call_1" +@requires_sdk +async def test_agent_startup_projects_constructor_registered_tools(chat_client_base: MockBaseChatClient) -> None: + guard = AllowGuard() + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[create_agent_hooks_middleware([guard])], + ) + + await agent.run("hello") + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] + + @requires_sdk async def test_input_projection_is_faithful(chat_client_base: MockBaseChatClient) -> None: guard = AllowGuard() From a36f7fecf84b42da126b904d986b89abdf13450f Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Mon, 10 Aug 2026 09:34:57 -0700 Subject: [PATCH 2/4] fix(python): merge configured tools in hook projection --- .../core/agent_framework/_agent_hooks.py | 29 +++++--- .../core/tests/core/test_agent_hooks.py | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index b2c7a3c216..aa5e438c71 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -857,21 +857,28 @@ def _agent_updates_from_response(response: AgentResponse[Any]) -> list[AgentResp def _tool_names(context: AgentContext) -> list[str]: """Project the registered tool names for ``agent_startup`` (spec ``tools_registered``).""" - from ._tools import _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage] + from ._tools import _append_unique_tools, _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage] - if context.tools is not None: - tools: Any = context.tools + default_options = getattr(context.agent, "default_options", None) + if isinstance(default_options, Mapping) and "tools" in default_options: + configured_tools: Any = default_options["tools"] else: - tools = getattr(context.agent, "tools", None) - default_options = getattr(context.agent, "default_options", None) - if tools is None and isinstance(default_options, Mapping): - tools = default_options.get("tools") - if tools is None: - return [] + configured_tools = getattr(context.agent, "tools", None) + + # Agent._prepare_run_context uses the named run-level tools when present and + # otherwise consumes options["tools"]. Mirror that precedence here so the + # startup projection describes the same run that reaches the model. + run_tools = context.tools + if run_tools is None and isinstance(context.options, Mapping): + run_tools = context.options.get("tools") + try: - normalized = normalize_tools(tools) + normalized = _append_unique_tools( + normalize_tools(configured_tools), + normalize_tools(run_tools), + ) except Exception: - logger.warning("agent-hooks could not normalize the run's tools for the agent_startup projection.") + logger.warning("agent-hooks could not normalize the agent's tools for the agent_startup projection.") return [] names: list[str] = [] for item in normalized: diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index de347f3836..cbf7de9687 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -291,6 +291,74 @@ async def test_agent_startup_projects_constructor_registered_tools(chat_client_b assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] +@requires_sdk +async def test_agent_startup_projects_configured_and_run_tools(chat_client_base: MockBaseChatClient) -> None: + @tool(approval_mode="never_require") + def runtime_tool(location: str) -> str: + """Look up a location supplied at runtime.""" + return f"runtime weather in {location}" + + guard = AllowGuard() + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[create_agent_hooks_middleware([guard])], + ) + + await agent.run("hello", tools=[runtime_tool]) + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "runtime_tool"] + + +@requires_sdk +async def test_agent_startup_projects_configured_and_options_tools(chat_client_base: MockBaseChatClient) -> None: + @tool(approval_mode="never_require") + def options_tool(location: str) -> str: + """Look up a location supplied through run options.""" + return f"options weather in {location}" + + guard = AllowGuard() + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[create_agent_hooks_middleware([guard])], + ) + + await agent.run("hello", options={"tools": [options_tool]}) + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "options_tool"] + + +@requires_sdk +async def test_agent_startup_prefers_named_run_tools_over_options(chat_client_base: MockBaseChatClient) -> None: + @tool(approval_mode="never_require") + def named_tool(location: str) -> str: + """Look up a location supplied through the named argument.""" + return f"named weather in {location}" + + @tool(approval_mode="never_require") + def ignored_options_tool(location: str) -> str: + """Look up a location supplied through run options.""" + return f"ignored options weather in {location}" + + guard = AllowGuard() + agent = Agent( + client=chat_client_base, + tools=[weather_tool], + middleware=[create_agent_hooks_middleware([guard])], + ) + + await agent.run("hello", tools=[named_tool], options={"tools": [ignored_options_tool]}) + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "named_tool"] + + @requires_sdk async def test_input_projection_is_faithful(chat_client_base: MockBaseChatClient) -> None: guard = AllowGuard() From 37e382177d447824cfa7ac9beb7b7f4b43756c28 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Tue, 11 Aug 2026 13:44:53 -0700 Subject: [PATCH 3/4] fix(python): include MCP tools in hook startup --- .../core/agent_framework/_agent_hooks.py | 58 ++++++++++++++++--- .../core/tests/core/test_agent_hooks.py | 22 +++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index aa5e438c71..5d9d1a4258 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -855,9 +855,10 @@ def _agent_updates_from_response(response: AgentResponse[Any]) -> list[AgentResp return updates -def _tool_names(context: AgentContext) -> list[str]: +async def _tool_names(context: AgentContext) -> list[str]: """Project the registered tool names for ``agent_startup`` (spec ``tools_registered``).""" - from ._tools import _append_unique_tools, _get_tool_name, normalize_tools # type: ignore[reportPrivateUsage] + from ._mcp import MCPTool + from ._tools import _append_unique_tools, _get_tool_name, normalize_tools # pyright: ignore[reportPrivateUsage] default_options = getattr(context.agent, "default_options", None) if isinstance(default_options, Mapping) and "tools" in default_options: @@ -873,13 +874,56 @@ def _tool_names(context: AgentContext) -> list[str]: run_tools = context.options.get("tools") try: - normalized = _append_unique_tools( - normalize_tools(configured_tools), - normalize_tools(run_tools), - ) + configured = normalize_tools(configured_tools) + run = normalize_tools(run_tools) except Exception: logger.warning("agent-hooks could not normalize the agent's tools for the agent_startup projection.") return [] + + normalized: list[Any] = [] + seen_mcp_tools: list[MCPTool] = [] + mcp_duplicate_message = "Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool." + + async def append_mcp_tools(mcp_tool: MCPTool) -> None: + if any(mcp_tool is seen_tool for seen_tool in seen_mcp_tools): + return + seen_mcp_tools.append(mcp_tool) + + if not mcp_tool.is_connected: + exit_stack = getattr(context.agent, "_async_exit_stack", None) + if exit_stack is None: + logger.warning( + "agent-hooks could not connect MCP tool %r for the agent_startup projection.", mcp_tool.name + ) + return + await exit_stack.enter_async_context(mcp_tool) + + _append_unique_tools( + normalized, + mcp_tool.functions, + duplicate_error_message=mcp_duplicate_message, + ) + + try: + for item in configured: + if isinstance(item, MCPTool): + await append_mcp_tools(item) + else: + _append_unique_tools(normalized, [item]) + + for item in run: + if isinstance(item, MCPTool): + await append_mcp_tools(item) + else: + _append_unique_tools(normalized, [item]) + + for item in getattr(context.agent, "mcp_tools", ()) or (): + if isinstance(item, MCPTool): + await append_mcp_tools(item) + except Exception: + logger.warning("agent-hooks could not resolve the agent's tools for the agent_startup projection.") + return [] + names: list[str] = [] for item in normalized: name = _get_tool_name(item) @@ -985,7 +1029,7 @@ def _new_run_state(self, context: AgentContext) -> _RunState: async def _emit_run_start(self, context: AgentContext, state: _RunState) -> None: """Emit ``agent_startup`` (per-run sessions) and ``input``; apply input transforms.""" if not state.session_scoped: - await state.emitter.emit(state.builder.agent_startup(tools_registered=_tool_names(context))) + await state.emitter.emit(state.builder.agent_startup(tools_registered=await _tool_names(context))) before = _InputCodec.to_wire(context.messages) outcome: EmitOutcome = await state.emitter.emit( state.builder.input(content=before["content"], role=before["role"]) diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index cbf7de9687..0b9b2208f2 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -291,6 +291,28 @@ async def test_agent_startup_projects_constructor_registered_tools(chat_client_b assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] +@requires_sdk +async def test_agent_startup_projects_constructor_mcp_tools(chat_client_base: MockBaseChatClient) -> None: + from agent_framework._mcp import MCPTool + + mcp_tool = MCPTool(name="weather-server", load_tools=False, load_prompts=False) # type: ignore[abstract] + mcp_tool.functions.append(weather_tool) + mcp_tool.is_connected = True + + guard = AllowGuard() + agent = Agent( + client=chat_client_base, + tools=[mcp_tool], + middleware=[create_agent_hooks_middleware([guard])], + ) + + await agent.run("hello") + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] + + @requires_sdk async def test_agent_startup_projects_configured_and_run_tools(chat_client_base: MockBaseChatClient) -> None: @tool(approval_mode="never_require") From a0c1732d52acb2278beaccfb732b97f176d47126 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Tue, 11 Aug 2026 13:59:29 -0700 Subject: [PATCH 4/4] fix(python): keep hook projection focused --- .../core/agent_framework/_agent_hooks.py | 75 +++------------- .../core/tests/core/test_agent_hooks.py | 90 ------------------- 2 files changed, 12 insertions(+), 153 deletions(-) diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 5d9d1a4258..71fe28884f 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -855,75 +855,24 @@ def _agent_updates_from_response(response: AgentResponse[Any]) -> list[AgentResp return updates -async def _tool_names(context: AgentContext) -> list[str]: +def _tool_names(context: AgentContext) -> list[str]: """Project the registered tool names for ``agent_startup`` (spec ``tools_registered``).""" - from ._mcp import MCPTool - from ._tools import _append_unique_tools, _get_tool_name, normalize_tools # pyright: ignore[reportPrivateUsage] + from ._tools import _get_tool_name, normalize_tools # pyright: ignore[reportPrivateUsage] - default_options = getattr(context.agent, "default_options", None) - if isinstance(default_options, Mapping) and "tools" in default_options: - configured_tools: Any = default_options["tools"] + if context.tools is not None: + tools: Any = context.tools else: - configured_tools = getattr(context.agent, "tools", None) - - # Agent._prepare_run_context uses the named run-level tools when present and - # otherwise consumes options["tools"]. Mirror that precedence here so the - # startup projection describes the same run that reaches the model. - run_tools = context.tools - if run_tools is None and isinstance(context.options, Mapping): - run_tools = context.options.get("tools") - - try: - configured = normalize_tools(configured_tools) - run = normalize_tools(run_tools) - except Exception: - logger.warning("agent-hooks could not normalize the agent's tools for the agent_startup projection.") + tools = getattr(context.agent, "tools", None) + default_options = getattr(context.agent, "default_options", None) + if tools is None and isinstance(default_options, Mapping): + tools = cast(Any, cast(Mapping[str, Any], default_options).get("tools")) + if tools is None: return [] - - normalized: list[Any] = [] - seen_mcp_tools: list[MCPTool] = [] - mcp_duplicate_message = "Tool names must be unique. Consider setting `tool_name_prefix` on the MCPTool." - - async def append_mcp_tools(mcp_tool: MCPTool) -> None: - if any(mcp_tool is seen_tool for seen_tool in seen_mcp_tools): - return - seen_mcp_tools.append(mcp_tool) - - if not mcp_tool.is_connected: - exit_stack = getattr(context.agent, "_async_exit_stack", None) - if exit_stack is None: - logger.warning( - "agent-hooks could not connect MCP tool %r for the agent_startup projection.", mcp_tool.name - ) - return - await exit_stack.enter_async_context(mcp_tool) - - _append_unique_tools( - normalized, - mcp_tool.functions, - duplicate_error_message=mcp_duplicate_message, - ) - try: - for item in configured: - if isinstance(item, MCPTool): - await append_mcp_tools(item) - else: - _append_unique_tools(normalized, [item]) - - for item in run: - if isinstance(item, MCPTool): - await append_mcp_tools(item) - else: - _append_unique_tools(normalized, [item]) - - for item in getattr(context.agent, "mcp_tools", ()) or (): - if isinstance(item, MCPTool): - await append_mcp_tools(item) + normalized = normalize_tools(tools) except Exception: - logger.warning("agent-hooks could not resolve the agent's tools for the agent_startup projection.") + logger.warning("agent-hooks could not normalize the run's tools for the agent_startup projection.") return [] - names: list[str] = [] for item in normalized: name = _get_tool_name(item) @@ -1029,7 +978,7 @@ def _new_run_state(self, context: AgentContext) -> _RunState: async def _emit_run_start(self, context: AgentContext, state: _RunState) -> None: """Emit ``agent_startup`` (per-run sessions) and ``input``; apply input transforms.""" if not state.session_scoped: - await state.emitter.emit(state.builder.agent_startup(tools_registered=await _tool_names(context))) + await state.emitter.emit(state.builder.agent_startup(tools_registered=_tool_names(context))) before = _InputCodec.to_wire(context.messages) outcome: EmitOutcome = await state.emitter.emit( state.builder.input(content=before["content"], role=before["role"]) diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index 0b9b2208f2..de347f3836 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -291,96 +291,6 @@ async def test_agent_startup_projects_constructor_registered_tools(chat_client_b assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] -@requires_sdk -async def test_agent_startup_projects_constructor_mcp_tools(chat_client_base: MockBaseChatClient) -> None: - from agent_framework._mcp import MCPTool - - mcp_tool = MCPTool(name="weather-server", load_tools=False, load_prompts=False) # type: ignore[abstract] - mcp_tool.functions.append(weather_tool) - mcp_tool.is_connected = True - - guard = AllowGuard() - agent = Agent( - client=chat_client_base, - tools=[mcp_tool], - middleware=[create_agent_hooks_middleware([guard])], - ) - - await agent.run("hello") - - startup = guard.contexts_for("agent_startup") - assert len(startup) == 1 - assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool"] - - -@requires_sdk -async def test_agent_startup_projects_configured_and_run_tools(chat_client_base: MockBaseChatClient) -> None: - @tool(approval_mode="never_require") - def runtime_tool(location: str) -> str: - """Look up a location supplied at runtime.""" - return f"runtime weather in {location}" - - guard = AllowGuard() - agent = Agent( - client=chat_client_base, - tools=[weather_tool], - middleware=[create_agent_hooks_middleware([guard])], - ) - - await agent.run("hello", tools=[runtime_tool]) - - startup = guard.contexts_for("agent_startup") - assert len(startup) == 1 - assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "runtime_tool"] - - -@requires_sdk -async def test_agent_startup_projects_configured_and_options_tools(chat_client_base: MockBaseChatClient) -> None: - @tool(approval_mode="never_require") - def options_tool(location: str) -> str: - """Look up a location supplied through run options.""" - return f"options weather in {location}" - - guard = AllowGuard() - agent = Agent( - client=chat_client_base, - tools=[weather_tool], - middleware=[create_agent_hooks_middleware([guard])], - ) - - await agent.run("hello", options={"tools": [options_tool]}) - - startup = guard.contexts_for("agent_startup") - assert len(startup) == 1 - assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "options_tool"] - - -@requires_sdk -async def test_agent_startup_prefers_named_run_tools_over_options(chat_client_base: MockBaseChatClient) -> None: - @tool(approval_mode="never_require") - def named_tool(location: str) -> str: - """Look up a location supplied through the named argument.""" - return f"named weather in {location}" - - @tool(approval_mode="never_require") - def ignored_options_tool(location: str) -> str: - """Look up a location supplied through run options.""" - return f"ignored options weather in {location}" - - guard = AllowGuard() - agent = Agent( - client=chat_client_base, - tools=[weather_tool], - middleware=[create_agent_hooks_middleware([guard])], - ) - - await agent.run("hello", tools=[named_tool], options={"tools": [ignored_options_tool]}) - - startup = guard.contexts_for("agent_startup") - assert len(startup) == 1 - assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "named_tool"] - - @requires_sdk async def test_input_projection_is_faithful(chat_client_base: MockBaseChatClient) -> None: guard = AllowGuard()