diff --git a/python/packages/core/agent_framework/_agent_hooks.py b/python/packages/core/agent_framework/_agent_hooks.py index 31c52f235d..a8fbdfd7f1 100644 --- a/python/packages/core/agent_framework/_agent_hooks.py +++ b/python/packages/core/agent_framework/_agent_hooks.py @@ -857,21 +857,44 @@ 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] + """Project the registered tool names for ``agent_startup`` (spec ``tools_registered``). + + The projection mirrors run preparation: constructor tools, agent/run options tools, + and the run-level tool overrides are all combined, so a run that supplies extra + tools still reports the agent's configured tools alongside them. + """ + from ._tools import _get_tool_name, normalize_tools # pyright: ignore[reportPrivateUsage] + + merged: list[Any] = [] + + def _extend(source: Any) -> None: + if source is None: + return + try: + merged.extend(normalize_tools(source)) + except Exception: + logger.warning( + "agent-hooks could not normalize the run's tools for the agent_startup projection." + ) + + agent = context.agent + _extend(getattr(agent, "tools", None)) + default_options = getattr(agent, "default_options", None) + if isinstance(default_options, Mapping): + _extend(cast(Mapping[str, Any], default_options).get("tools")) + options = context.options + if isinstance(options, Mapping): + _extend(cast(Mapping[str, Any], options).get("tools")) + _extend(context.tools) - tools: Any = context.tools if context.tools is not None else getattr(context.agent, "tools", None) - if tools is None: - return [] - try: - normalized = normalize_tools(tools) - except Exception: - logger.warning("agent-hooks could not normalize the run's tools for the agent_startup projection.") - return [] names: list[str] = [] - for item in normalized: + seen: set[str] = set() + for item in merged: name = _get_tool_name(item) - names.append(name if name else type(item).__name__) + label = name if name else type(item).__name__ + if label not in seen: + seen.add(label) + names.append(label) return names diff --git a/python/packages/core/tests/core/test_agent_hooks.py b/python/packages/core/tests/core/test_agent_hooks.py index a13b96baa0..44a80f88c3 100644 --- a/python/packages/core/tests/core/test_agent_hooks.py +++ b/python/packages/core/tests/core/test_agent_hooks.py @@ -106,6 +106,12 @@ def weather_tool(location: str) -> str: return f"weather in {location}" +@tool(approval_mode="never_require") +def search_tool(query: str) -> str: + """Run a search.""" + return f"results for {query}" + + weather_tool_calls: list[str] = [] @@ -293,6 +299,40 @@ 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_agent_startup_merges_run_tools_with_constructor_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", tools=[search_tool]) + + startup = guard.contexts_for("agent_startup") + assert len(startup) == 1 + assert startup[0]["agent_init"]["tools_registered"] == ["weather_tool", "search_tool"] + + @requires_sdk async def test_input_projection_is_faithful(chat_client_base: MockBaseChatClient) -> None: guard = AllowGuard()