Skip to content

Commit 2dbc29d

Browse files
committed
feat:新增demo_frontend_agent支持前端工具交互验证
1 parent c45c770 commit 2dbc29d

7 files changed

Lines changed: 211 additions & 1 deletion

File tree

agents/matmaster_agent/flow_agents/agent.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@
119119
PLAN,
120120
UPLOAD_FILE,
121121
)
122+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent import (
123+
DemoFrontendAgent,
124+
)
125+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent.constant import (
126+
DEMO_FRONTEND_AGENT_NAME,
127+
)
128+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent.tool import (
129+
demo_frontend_tool,
130+
)
122131
from agents.matmaster_agent.sub_agents.mapping import (
123132
AGENT_CLASS_MAPPING,
124133
ALL_AGENT_TOOLS_LIST,
@@ -218,6 +227,13 @@ def after_init(self):
218227
instruction='',
219228
)
220229

230+
self._demo_frontend_agent = DemoFrontendAgent(
231+
name=DEMO_FRONTEND_AGENT_NAME,
232+
model=MatMasterLlmConfig.default_litellm_model,
233+
instruction='',
234+
tools=[demo_frontend_tool],
235+
)
236+
221237
self.sub_agents = [
222238
self.chat_agent,
223239
self.handle_upload_agent,
@@ -804,6 +820,10 @@ async def _run_async_impl(
804820
yield quota_remaining_event
805821
return
806822

823+
# 在需要验证的节点调用内置 demo_frontend_agent(其内挂 demo_frontend_tool,同一轮 run 内等前端回传)
824+
async for demo_event in self._demo_frontend_agent.run_async(ctx):
825+
yield demo_event
826+
807827
# 上传文件特殊处理
808828
async for handle_upload_event in self.handle_upload_agent.run_async(ctx):
809829
yield handle_upload_event

agents/matmaster_agent/flow_agents/constant.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
MATMASTER_FLOW = 'matmaster_flow'
1010
MATMASTER_FLOW_PLANS = 'matmaster_flow_plans'
1111
MATMASTER_GENERATE_NPS = 'matmaster_generate_nps'
12+
# 前端 demo 工具名;ADK 只下发 function_call,等前端执行后回传 function_response 再继续
13+
DEMO_FRONTEND_TOOL = 'demo_frontend_tool'
14+
# 前端 tool 返回值写入 session.state 的 key,后续逻辑可用 ctx.session.state[DEMO_FRONTEND_TOOL_RESULT_STATE_KEY] 读取
15+
DEMO_FRONTEND_TOOL_RESULT_STATE_KEY = 'demo_frontend_tool_result'
1216

1317
# matmaster_flow 展示文案,直接传给前端(正常执行无标签则传空字符串)
1418
EXECUTION_TYPE_LABEL_RETRY = '重试工具'

agents/matmaster_agent/flow_agents/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import re
3-
from typing import List
3+
from typing import Any, List, Optional
44

55
from google.adk.agents import InvocationContext
66

@@ -47,6 +47,25 @@ def get_tools_list(ctx: InvocationContext, scenes: list):
4747
return final_tools_list
4848

4949

50+
def get_latest_frontend_tool_response(session: Any, tool_name: str) -> Optional[dict]:
51+
"""
52+
从 session.events 中查找最近一次名为 tool_name 的前端 tool 的 function_response,
53+
用于「ADK 调用前端 tool → 前端返回 → ADK 继续」的闭环。
54+
若前端通过 API 已把 function_response 追加到 session,这里能拿到返回值。
55+
"""
56+
events = getattr(session, 'events', None) or []
57+
for event in reversed(events):
58+
if not getattr(event, 'content', None) or not getattr(
59+
event.content, 'parts', None
60+
):
61+
continue
62+
for part in event.content.parts:
63+
fr = getattr(part, 'function_response', None)
64+
if fr and getattr(fr, 'name', None) == tool_name:
65+
return getattr(fr, 'response', None)
66+
return None
67+
68+
5069
def get_agent_name(tool_name, sub_agents):
5170
try:
5271
target_agent_name = ALL_TOOLS[tool_name]['belonging_agent']
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent.agent import (
2+
DemoFrontendAgent,
3+
)
4+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent.tool import (
5+
set_frontend_tool_result,
6+
wait_for_frontend_tool_result,
7+
)
8+
9+
__all__ = [
10+
'DemoFrontendAgent',
11+
'set_frontend_tool_result',
12+
'wait_for_frontend_tool_result',
13+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import logging
2+
import uuid
3+
from typing import AsyncGenerator
4+
5+
from google.adk.agents import InvocationContext
6+
from google.adk.events import Event
7+
8+
from agents.matmaster_agent.constant import ModelRole
9+
from agents.matmaster_agent.core_agents.public_agents.sync_agent import (
10+
BaseSyncAgentWithToolValidator,
11+
)
12+
from agents.matmaster_agent.flow_agents.constant import (
13+
DEMO_FRONTEND_TOOL,
14+
DEMO_FRONTEND_TOOL_RESULT_STATE_KEY,
15+
)
16+
from agents.matmaster_agent.sub_agents.built_in_agent.demo_frontend_agent.tool import (
17+
wait_for_frontend_tool_result,
18+
)
19+
from agents.matmaster_agent.utils.event_utils import (
20+
context_function_call_event,
21+
update_state_event,
22+
)
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class DemoFrontendAgent(BaseSyncAgentWithToolValidator):
28+
async def _run_events(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
29+
function_call_id = f"added_{str(uuid.uuid4()).replace('-', '')[:24]}"
30+
# 在 args 里带上三 id,前端用户确认/取消后可用以请求 POST /awp/frontend-tool-result
31+
yield context_function_call_event(
32+
ctx,
33+
self.name,
34+
function_call_id,
35+
DEMO_FRONTEND_TOOL,
36+
ModelRole,
37+
{
38+
'message': '请确认',
39+
'title': '前端 Demo Tool',
40+
'session_id': ctx.session.id,
41+
'invocation_id': ctx.invocation_id,
42+
'function_call_id': function_call_id,
43+
},
44+
)
45+
demo_result = await wait_for_frontend_tool_result(
46+
ctx.session.id, ctx.invocation_id, function_call_id
47+
)
48+
ctx.session.state[DEMO_FRONTEND_TOOL_RESULT_STATE_KEY] = demo_result
49+
yield update_state_event(
50+
ctx, state_delta={DEMO_FRONTEND_TOOL_RESULT_STATE_KEY: demo_result}
51+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DEMO_FRONTEND_AGENT_NAME = 'demo_frontend_agent'
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
前端 Demo Tool:ADK 侧注册为 FunctionTool,实际执行由前端完成,返回值在同轮 run 内回传。
3+
4+
- 本 tool 挂在 demo_frontend_agent 的 tools 里;flow 在需要验证的节点调用该 agent 即可。
5+
- 前端收到 function_call 后展示卡片,用户操作后通过 API 调用 set_frontend_tool_result
6+
传入 (session_id, invocation_id, function_call_id, result),本侧 await 即返回并继续。
7+
"""
8+
9+
import asyncio
10+
import logging
11+
from typing import Dict
12+
13+
from google.adk.tools import ToolContext
14+
15+
logger = logging.getLogger(__name__)
16+
17+
# (session_id, invocation_id, function_call_id) -> asyncio.Future[dict]
18+
_pending: Dict[tuple, asyncio.Future] = {}
19+
_DEFAULT_TIMEOUT = 60.0
20+
21+
22+
def _key(session_id: str, invocation_id: str, function_call_id: str) -> tuple:
23+
return (session_id, invocation_id, function_call_id)
24+
25+
26+
async def wait_for_frontend_tool_result(
27+
session_id: str,
28+
invocation_id: str,
29+
function_call_id: str,
30+
timeout: float = _DEFAULT_TIMEOUT,
31+
) -> dict:
32+
"""
33+
在同一轮 run 内阻塞等待前端回传的 tool 结果。
34+
前端通过 API 调用 set_frontend_tool_result(session_id, invocation_id, function_call_id, result) 后,本函数返回 result。
35+
"""
36+
k = _key(session_id, invocation_id, function_call_id)
37+
if k not in _pending:
38+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
39+
_pending[k] = fut
40+
try:
41+
return await asyncio.wait_for(_pending[k], timeout=timeout)
42+
finally:
43+
_pending.pop(k, None)
44+
45+
46+
def set_frontend_tool_result(
47+
session_id: str,
48+
invocation_id: str,
49+
function_call_id: str,
50+
result: dict,
51+
) -> None:
52+
"""
53+
由运行时 API 在收到前端提交的 tool 结果时调用,用于解除 wait_for_frontend_tool_result 的阻塞。
54+
"""
55+
k = _key(session_id, invocation_id, function_call_id)
56+
fut = _pending.get(k)
57+
if fut is not None and not fut.done():
58+
fut.set_result(result)
59+
logger.info(
60+
'demo_frontend_tool result set for session_id=%s invocation_id=%s function_call_id=%s',
61+
session_id,
62+
invocation_id,
63+
function_call_id,
64+
)
65+
else:
66+
logger.warning(
67+
'demo_frontend_tool no pending future for session_id=%s invocation_id=%s function_call_id=%s',
68+
session_id,
69+
invocation_id,
70+
function_call_id,
71+
)
72+
73+
74+
async def demo_frontend_tool(
75+
message: str,
76+
title: str,
77+
tool_context: ToolContext,
78+
) -> dict:
79+
"""
80+
Demo frontend tool for verifying ADK can call a client-side tool.
81+
实际执行由前端完成;本函数在此轮 run 内等待前端回传结果后返回。
82+
83+
Args:
84+
message: 展示给用户的文案,与前端约定一致。
85+
title: 卡片标题,与前端约定一致。
86+
87+
Returns:
88+
前端回传的 dict,例如 {"confirmed": True, "value": "..."}。
89+
"""
90+
session_id = tool_context.session.id
91+
invocation_id = getattr(tool_context, 'invocation_id', None) or ''
92+
function_call_id = getattr(tool_context, 'function_call_id', None) or ''
93+
return await wait_for_frontend_tool_result(
94+
session_id, invocation_id, function_call_id
95+
)
96+
97+
98+
# 供 ADK 自动从函数签名生成 schema 的 docstring(参数与前端一致)
99+
demo_frontend_tool.__doc__ = (
100+
'Demo frontend tool for verifying ADK can call a client-side tool. '
101+
'Renders a card on the frontend (message, title); execution and return value are provided by the frontend.'
102+
)

0 commit comments

Comments
 (0)