Skip to content

Commit 32b84c3

Browse files
wuliang229GWeale
authored andcommitted
feat: Add /apps/{app_name}/app-info endpoint to ADK CLI web server
This endpoint provides detailed **static** information about an application's agents, including their description, instructions, tools, and sub-agents, specifically for LlmAgent instances. Co-authored-by: Liang Wu <wuliang@google.com> PiperOrigin-RevId: 895470828 Change-Id: I16c88b440087aec1698b3db1f80ba20f06c36eeb
1 parent 6985cf8 commit 32b84c3

File tree

3 files changed

+295
-0
lines changed

3 files changed

+295
-0
lines changed

src/google/adk/cli/adk_web_server.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
from ..agents.base_agent import BaseAgent
6161
from ..agents.live_request_queue import LiveRequest
6262
from ..agents.live_request_queue import LiveRequestQueue
63+
from ..agents.llm_agent import LlmAgent
64+
from ..agents.llm_agent import ToolUnion
6365
from ..agents.run_config import RunConfig
6466
from ..agents.run_config import StreamingMode
6567
from ..apps.app import App
@@ -90,7 +92,10 @@
9092
from ..runners import Runner
9193
from ..sessions.base_session_service import BaseSessionService
9294
from ..sessions.session import Session
95+
from ..utils.agent_info import AgentInfo
96+
from ..utils.agent_info import get_agents_dict
9397
from ..utils.context_utils import Aclosing
98+
from ..utils.feature_decorator import experimental
9499
from ..version import __version__
95100
from ..workflow._node_status import NodeStatus
96101
from .cli_eval import EVAL_SESSION_ID_PREFIX
@@ -496,6 +501,7 @@ class AppInfo(common.BaseModel):
496501
description: str
497502
language: Literal["yaml", "python"]
498503
is_computer_use: bool = False
504+
agents: Optional[dict[str, AgentInfo]] = None
499505

500506

501507
class ListAppsResponse(common.BaseModel):
@@ -1061,6 +1067,25 @@ async def list_apps(
10611067
return ListAppsResponse(apps=[AppInfo(**app) for app in apps_info])
10621068
return self.agent_loader.list_agents()
10631069

1070+
@experimental
1071+
@app.get("/apps/{app_name}/app-info", response_model_exclude_none=True)
1072+
async def get_adk_app_info(app_name: str) -> AppInfo:
1073+
"""Returns the detailed info for a given ADK app."""
1074+
agent_or_app = self.agent_loader.load_agent(app_name)
1075+
root_agent = self._get_root_agent(agent_or_app)
1076+
if isinstance(root_agent, LlmAgent):
1077+
return AppInfo(
1078+
name=app_name,
1079+
root_agent_name=root_agent.name,
1080+
description=root_agent.description,
1081+
language="python",
1082+
agents=get_agents_dict(root_agent),
1083+
)
1084+
else:
1085+
raise HTTPException(
1086+
status_code=400, detail="Root agent is not an LlmAgent"
1087+
)
1088+
10641089
@app.get("/debug/trace/{event_id}", tags=[TAG_DEBUG])
10651090
async def get_trace_dict(event_id: str) -> Any:
10661091
event_dict = trace_dict.get(event_id, None)

src/google/adk/utils/agent_info.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from typing import Any
18+
19+
from google.genai import types
20+
import pydantic
21+
22+
from ..agents.llm_agent import LlmAgent
23+
from ..agents.llm_agent import ToolUnion
24+
from ..tools.base_tool import BaseTool
25+
from ..tools.base_toolset import BaseToolset
26+
from ..tools.function_tool import FunctionTool
27+
28+
29+
class AgentInfo(pydantic.BaseModel):
30+
name: str
31+
description: str
32+
instruction: str
33+
tools: list[types.Tool]
34+
sub_agents: list[str]
35+
36+
37+
def get_tools_info(tools: list[ToolUnion]) -> list[Any]:
38+
"""Returns the info for a given list of tools."""
39+
final_tools = []
40+
for tool in tools:
41+
if isinstance(tool, BaseTool):
42+
final_tools.append(tool)
43+
elif isinstance(tool, BaseToolset):
44+
final_tools.extend(tool.get_tools())
45+
else:
46+
final_tools.append(FunctionTool(tool))
47+
return [
48+
types.Tool(function_declarations=[tool._get_declaration()])
49+
for tool in final_tools
50+
if tool._get_declaration()
51+
]
52+
53+
54+
def get_agents_dict(agent: LlmAgent) -> dict[str, AgentInfo]:
55+
"""Returns a dict with info for the agent and its sub-agents."""
56+
agents_dict = {}
57+
58+
def _traverse(current_agent: LlmAgent):
59+
if current_agent.name in agents_dict:
60+
return
61+
62+
sub_agent_names = []
63+
for sub_agent in current_agent.sub_agents:
64+
if isinstance(sub_agent, LlmAgent):
65+
_traverse(sub_agent)
66+
sub_agent_names.append(sub_agent.name)
67+
68+
agents_dict[current_agent.name] = AgentInfo(
69+
name=current_agent.name,
70+
description=current_agent.description,
71+
instruction=current_agent.instruction,
72+
tools=get_tools_info(current_agent.tools),
73+
sub_agents=sub_agent_names,
74+
)
75+
76+
_traverse(agent)
77+
return agents_dict

tests/unittests/cli/test_fast_api.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from fastapi.testclient import TestClient
2828
from google.adk.agents.base_agent import BaseAgent
29+
from google.adk.agents.llm_agent import LlmAgent
2930
from google.adk.agents.run_config import RunConfig
3031
from google.adk.apps.app import App
3132
from google.adk.artifacts.base_artifact_service import ArtifactVersion
@@ -801,6 +802,198 @@ def test_list_apps_detailed(test_app):
801802
logger.info(f"Listed apps: {data}")
802803

803804

805+
def test_get_adk_app_info_llm_agent(test_app, mock_agent_loader):
806+
"""Test retrieving app info when root agent is an LlmAgent."""
807+
agent = LlmAgent(
808+
name="test_llm_agent", description="test description", model="test_model"
809+
)
810+
with patch.object(mock_agent_loader, "load_agent", return_value=agent):
811+
response = test_app.get("/apps/test_app/app-info")
812+
assert response.status_code == 200
813+
data = response.json()
814+
assert data["name"] == "test_app"
815+
assert data["rootAgentName"] == "test_llm_agent"
816+
assert data["description"] == "test description"
817+
assert data["language"] == "python"
818+
assert "agents" in data
819+
assert "test_llm_agent" in data["agents"]
820+
821+
822+
def test_get_adk_app_info_llm_agent_with_subagents(test_app, mock_agent_loader):
823+
"""Test retrieving app info when root agent is an LlmAgent with sub_agents and tools."""
824+
825+
def sub_tool1(a: int) -> str:
826+
"""Sub tool 1."""
827+
return str(a)
828+
829+
def sub_tool2(b: str) -> str:
830+
"""Sub tool 2."""
831+
return b
832+
833+
sub_agent1 = LlmAgent(
834+
name="sub_agent1",
835+
description="sub description 1",
836+
model="test_model",
837+
tools=[sub_tool1],
838+
)
839+
sub_agent2 = LlmAgent(
840+
name="sub_agent2",
841+
description="sub description 2",
842+
model="test_model",
843+
tools=[sub_tool2],
844+
)
845+
agent = LlmAgent(
846+
name="test_llm_agent",
847+
description="test description",
848+
model="test_model",
849+
sub_agents=[sub_agent1, sub_agent2],
850+
)
851+
with patch.object(mock_agent_loader, "load_agent", return_value=agent):
852+
response = test_app.get("/apps/test_app/app-info")
853+
assert response.status_code == 200
854+
data = response.json()
855+
assert data["rootAgentName"] == "test_llm_agent"
856+
assert "test_llm_agent" in data["agents"]
857+
assert "sub_agent1" in data["agents"]
858+
assert "sub_agent2" in data["agents"]
859+
860+
# Verify tools for sub_agent1
861+
agent1_info = data["agents"]["sub_agent1"]
862+
assert "tools" in agent1_info
863+
assert len(agent1_info["tools"]) == 1
864+
tool1 = agent1_info["tools"][0]
865+
field_name1 = (
866+
"functionDeclarations"
867+
if "functionDeclarations" in tool1
868+
else "function_declarations"
869+
)
870+
assert field_name1 in tool1
871+
assert tool1[field_name1][0]["name"] == "sub_tool1"
872+
873+
# Verify tools for sub_agent2
874+
agent2_info = data["agents"]["sub_agent2"]
875+
assert "tools" in agent2_info
876+
assert len(agent2_info["tools"]) == 1
877+
tool2 = agent2_info["tools"][0]
878+
field_name2 = (
879+
"functionDeclarations"
880+
if "functionDeclarations" in tool2
881+
else "function_declarations"
882+
)
883+
assert field_name2 in tool2
884+
assert tool2[field_name2][0]["name"] == "sub_tool2"
885+
886+
887+
def test_get_adk_app_info_triple_nested_agents_with_tools(
888+
test_app, mock_agent_loader
889+
):
890+
"""Test retrieving app info when there are triple nested agents with tools."""
891+
892+
def tool1(a: int) -> str:
893+
"""Tool 1."""
894+
return str(a)
895+
896+
def tool2(b: str) -> str:
897+
"""Tool 2."""
898+
return b
899+
900+
def tool3(c: float) -> str:
901+
"""Tool 3."""
902+
return str(c)
903+
904+
# Level 3 (deepest)
905+
agent3 = LlmAgent(
906+
name="agent3",
907+
description="Level 3 agent",
908+
model="test_model",
909+
tools=[tool3],
910+
)
911+
912+
# Level 2
913+
agent2 = LlmAgent(
914+
name="agent2",
915+
description="Level 2 agent",
916+
model="test_model",
917+
tools=[tool2],
918+
sub_agents=[agent3],
919+
)
920+
921+
# Level 1 (root)
922+
root_agent = LlmAgent(
923+
name="root_agent",
924+
description="Level 1 agent",
925+
model="test_model",
926+
tools=[tool1],
927+
sub_agents=[agent2],
928+
)
929+
930+
with patch.object(mock_agent_loader, "load_agent", return_value=root_agent):
931+
response = test_app.get("/apps/test_app/app-info")
932+
assert response.status_code == 200
933+
data = response.json()
934+
assert data["rootAgentName"] == "root_agent"
935+
assert "root_agent" in data["agents"]
936+
assert "agent2" in data["agents"]
937+
assert "agent3" in data["agents"]
938+
939+
# Verify each has its tools
940+
for agent_name, exp_tool_name in [
941+
("root_agent", "tool1"),
942+
("agent2", "tool2"),
943+
("agent3", "tool3"),
944+
]:
945+
ai = data["agents"][agent_name]
946+
assert len(ai["tools"]) == 1
947+
tool = ai["tools"][0]
948+
field_name = (
949+
"functionDeclarations"
950+
if "functionDeclarations" in tool
951+
else "function_declarations"
952+
)
953+
assert tool[field_name][0]["name"] == exp_tool_name
954+
955+
956+
def test_get_adk_app_info_llm_agent_with_function_tool(
957+
test_app, mock_agent_loader
958+
):
959+
"""Test retrieving app info when root agent has tools."""
960+
961+
def my_tool(a: int, b: str) -> str:
962+
"""A dummy tool function."""
963+
return f"{a} {b}"
964+
965+
agent = LlmAgent(
966+
name="test_llm_agent",
967+
description="test description",
968+
model="test_model",
969+
tools=[my_tool],
970+
)
971+
with patch.object(mock_agent_loader, "load_agent", return_value=agent):
972+
response = test_app.get("/apps/test_app/app-info")
973+
assert response.status_code == 200
974+
data = response.json()
975+
assert data["rootAgentName"] == "test_llm_agent"
976+
assert "test_llm_agent" in data["agents"]
977+
agent_info = data["agents"]["test_llm_agent"]
978+
assert "tools" in agent_info
979+
assert len(agent_info["tools"]) == 1
980+
981+
# Verify tool serialization
982+
tool = agent_info["tools"][0]
983+
func_decls = tool["functionDeclarations"]
984+
assert len(func_decls) == 1
985+
assert func_decls[0]["name"] == "my_tool"
986+
987+
988+
def test_get_adk_app_info_non_llm_agent(test_app, mock_agent_loader):
989+
"""Test retrieving app info when root agent is not an LlmAgent raises 400."""
990+
agent = DummyAgent("dummy_agent")
991+
with patch.object(mock_agent_loader, "load_agent", return_value=agent):
992+
response = test_app.get("/apps/test_app/app-info")
993+
assert response.status_code == 400
994+
assert "Root agent is not an LlmAgent" in response.json()["detail"]
995+
996+
804997
def test_create_session_with_id(test_app, test_session_info):
805998
"""Test creating a session with a specific ID."""
806999
new_session_id = "new_session_id"

0 commit comments

Comments
 (0)