Skip to content

Commit 10ba7c0

Browse files
wislapwehosHongzhi Wenclaude
authored
feat(mcp-adapter): expose MCP tool results to LLM;feat(plugin):improve result system (#526)
* feat(plugin): replace parse_plugin_result with build_plugin_reply_event - Remove parse_plugin_result import and _lookup_llm_result_fields function - Add build_plugin_reply_event import and implement _emit_plugin_completion_reply - Integrate new reply contract utilities in task_executor for structured plugin responses - Centralize plugin reply formatting and emission logic for consistency * feat(mcp-adapter): enhance tool result handling with structured payloads - Add `Any` to typing imports for improved type annotations - Refactor tool call response handling to use `_build_mcp_tool_payload()` method - Return structured payload via `finish()` with summary and metadata - Add helper methods for content description and result summarization: - `_truncate_llm_text()` for text truncation - `_string_field()` for safe string conversion - `_describe_mcp_content_item()` for content type formatting - `_summarize_mcp_result()` for result summarization - Improve response consistency and provide better context for downstream processing * 1 * 1 * refactor: simplify plugin reply system, remove overengineered reply_contract - Delete reply_contract.py (312 lines), plugin_reply.py (117 lines), test_plugin_reply_contract.py (224 lines) — candidate election system was solving a problem that doesn't exist (no plugin produces multiple competing exports) - Use existing llm_result_fields mechanism for field filtering: - Add llm_result_fields param to register_dynamic_entry() - MCP adapter declares llm_result_fields=["summary"] at registration instead of agent.fields at finish() time - Keep reply=True/False on finish() but implement as simple bool check (_is_reply_suppressed) instead of AgentReplySpec/ReplyCandidate pipeline - Restore _lookup_llm_result_fields + parse_plugin_result in agent_server (proven, simpler path) - Revert proactive_bridge to original simple logic - Add ja docs for reply control and llm_result_fields - Simplify en/zh-CN/ja docs and PLUGIN_DEVELOPMENT_GUIDE Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: _is_reply_suppressed reads meta from correct object; propagate llm_result_fields through IPC - _is_reply_suppressed now receives full result dict (which contains meta at root level) instead of result.data (which doesn't) - _notify_dynamic_entry_registered includes llm_result_fields in the ENTRY_UPDATE payload so it survives IPC to the host process - communication.py passes llm_result_fields through metadata dict when reconstructing EventMeta from IPC, matching the existing query_service transparent path Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: propagate meta from plugin_result to result_obj result_obj was missing the meta field from plugin_result, so _is_reply_suppressed could never read meta.agent.reply from the completion result. Add meta to the result_obj dict. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <wenguanjung@aliyun.com> Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d1e004 commit 10ba7c0

12 files changed

Lines changed: 384 additions & 73 deletions

File tree

agent_server.py

Lines changed: 61 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,18 @@ def _lookup_llm_result_fields(plugin_id: str, entry_id: Optional[str]) -> Option
645645
return None
646646

647647

648+
def _is_reply_suppressed(result: Optional[Dict]) -> bool:
649+
"""检查插件是否通过 meta.agent.reply=False 显式抑制回复。"""
650+
if not isinstance(result, dict):
651+
return False
652+
meta = result.get("meta")
653+
if not isinstance(meta, dict):
654+
return False
655+
agent = meta.get("agent")
656+
if not isinstance(agent, dict):
657+
return False
658+
return agent.get("reply") is False
659+
648660
def _check_agent_api_gate() -> Dict[str, Any]:
649661
"""统一 Agent API 门槛检查。"""
650662
try:
@@ -1207,6 +1219,8 @@ async def _run_user_plugin_dispatch():
12071219
plugin_message=_plugin_msg,
12081220
error=_error_to_pass,
12091221
)
1222+
# 检查插件是否通过 meta.agent.reply=False 抑制回复
1223+
_suppress_reply = _is_reply_suppressed(up_result.result if isinstance(up_result.result, dict) else None)
12101224
# 检查插件是否返回 deferred 标志(如备忘提醒:调度成功但提醒尚未触发)
12111225
is_deferred = isinstance(run_data, dict) and run_data.get("deferred") is True
12121226
# Update task_registry(deferred 任务保持 running,不写 terminal 状态)
@@ -1231,34 +1245,36 @@ async def _run_user_plugin_dispatch():
12311245
# 不进入后续 completed/failed 流程
12321246
elif up_result.success:
12331247
logger.info(f"[TaskExecutor] ✅ UserPlugin completed: {plugin_id}")
1234-
_lang = _rp_lang(None)
1235-
summary = _rp_phrase('plugin_done_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_done', _lang, id=plugin_id)
1236-
try:
1237-
await _emit_task_result(
1238-
lanlan_name,
1239-
channel="user_plugin",
1240-
task_id=str(up_result.task_id or ""),
1241-
success=True,
1242-
summary=summary[:500],
1243-
detail=detail,
1244-
)
1245-
except Exception as emit_err:
1246-
logger.debug("[TaskExecutor] emit task_result(success) failed: task_id=%s plugin_id=%s error=%s", up_result.task_id, plugin_id, emit_err)
1248+
if not _suppress_reply:
1249+
_lang = _rp_lang(None)
1250+
summary = _rp_phrase('plugin_done_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_done', _lang, id=plugin_id)
1251+
try:
1252+
await _emit_task_result(
1253+
lanlan_name,
1254+
channel="user_plugin",
1255+
task_id=str(up_result.task_id or ""),
1256+
success=True,
1257+
summary=summary[:500],
1258+
detail=detail,
1259+
)
1260+
except Exception as emit_err:
1261+
logger.debug("[TaskExecutor] emit task_result(success) failed: task_id=%s plugin_id=%s error=%s", up_result.task_id, plugin_id, emit_err)
12471262
else:
12481263
logger.warning(f"[TaskExecutor] ❌ UserPlugin failed: {up_result.error}")
1249-
_lang = _rp_lang(None)
1250-
try:
1251-
_fail_summary = _rp_phrase('plugin_failed_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_failed', _lang, id=plugin_id)
1252-
await _emit_task_result(
1253-
lanlan_name,
1254-
channel="user_plugin",
1255-
task_id=str(up_result.task_id or ""),
1256-
success=False,
1257-
summary=_fail_summary[:500],
1258-
error_message=(detail or str(up_result.error or ""))[:500],
1259-
)
1260-
except Exception as emit_err:
1261-
logger.debug("[TaskExecutor] emit task_result(failed) failed: task_id=%s plugin_id=%s error=%s", up_result.task_id, plugin_id, emit_err)
1264+
if not _suppress_reply:
1265+
_lang = _rp_lang(None)
1266+
try:
1267+
_fail_summary = _rp_phrase('plugin_failed_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_failed', _lang, id=plugin_id)
1268+
await _emit_task_result(
1269+
lanlan_name,
1270+
channel="user_plugin",
1271+
task_id=str(up_result.task_id or ""),
1272+
success=False,
1273+
summary=_fail_summary[:500],
1274+
error_message=(detail or str(up_result.error or ""))[:500],
1275+
)
1276+
except Exception as emit_err:
1277+
logger.debug("[TaskExecutor] emit task_result(failed) failed: task_id=%s plugin_id=%s error=%s", up_result.task_id, plugin_id, emit_err)
12621278
# Emit task_update (terminal) — deferred 任务跳过,保持 running
12631279
if not (up_result.success and is_deferred):
12641280
try:
@@ -1826,22 +1842,26 @@ async def _on_plugin_progress(
18261842
plugin_message=_plugin_msg,
18271843
error=_error_to_pass,
18281844
)
1829-
if not res.success:
1845+
_suppress_reply = _is_reply_suppressed(res.result if isinstance(res.result, dict) else None)
1846+
if not _suppress_reply:
1847+
if not res.success:
1848+
info["error"] = (detail or str(res.error or ""))[:500]
1849+
_lang = _rp_lang(None)
1850+
if res.success:
1851+
summary = _rp_phrase('plugin_done_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_done', _lang, id=plugin_id)
1852+
else:
1853+
summary = _rp_phrase('plugin_failed_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_failed', _lang, id=plugin_id)
1854+
await _emit_task_result(
1855+
lanlan_name,
1856+
channel="user_plugin",
1857+
task_id=task_id,
1858+
success=res.success,
1859+
summary=summary[:500],
1860+
detail=detail if res.success else "",
1861+
error_message=(detail or str(res.error or ""))[:500] if not res.success else "",
1862+
)
1863+
elif not res.success:
18301864
info["error"] = (detail or str(res.error or ""))[:500]
1831-
_lang = _rp_lang(None)
1832-
if res.success:
1833-
summary = _rp_phrase('plugin_done_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_done', _lang, id=plugin_id)
1834-
else:
1835-
summary = _rp_phrase('plugin_failed_with', _lang, id=plugin_id, detail=detail) if detail else _rp_phrase('plugin_failed', _lang, id=plugin_id)
1836-
await _emit_task_result(
1837-
lanlan_name,
1838-
channel="user_plugin",
1839-
task_id=task_id,
1840-
success=res.success,
1841-
summary=summary[:500],
1842-
detail=detail if res.success else "",
1843-
error_message=(detail or str(res.error or ""))[:500] if not res.success else "",
1844-
)
18451865
except Exception as emit_err:
18461866
logger.debug("[Plugin] emit task_result failed: task_id=%s plugin_id=%s error=%s", task_id, plugin_id, emit_err)
18471867
except asyncio.CancelledError:

brain/task_executor.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ class UserPluginDecision:
6464
plugin_args: Optional[Dict] = None
6565
reason: str = ""
6666

67-
6867
class DirectTaskExecutor:
6968
"""
7069
直接任务执行器:并行评估 BrowserUse / ComputerUse / UserPlugin 可行性并执行
@@ -981,7 +980,6 @@ async def _execute_user_plugin(
981980
entry_id=plugin_entry_id,
982981
reason=reason or "invalid_entry_id",
983982
)
984-
985983
# New run protocol: default path (POST /runs, return accepted immediately)
986984
try:
987985
runs_endpoint = f"http://127.0.0.1:{USER_PLUGIN_SERVER_PORT}/runs"
@@ -1088,7 +1086,11 @@ async def _execute_user_plugin(
10881086
"run_status": completion.get("status"),
10891087
"run_success": run_success,
10901088
"run_data": completion.get("data"),
1091-
"run_error": completion.get("error"),
1089+
"run_error": completion.get("run_error", completion.get("error")),
1090+
"meta": completion.get("meta"),
1091+
"message": completion.get("message"),
1092+
"progress": completion.get("progress"),
1093+
"stage": completion.get("stage"),
10921094
}
10931095
return TaskResult(
10941096
task_id=task_id,
@@ -1232,6 +1234,7 @@ async def _await_run_completion(
12321234
raw = item.get("json") or item.get("json_data")
12331235
if isinstance(raw, dict):
12341236
plugin_result["data"] = raw.get("data")
1237+
plugin_result["meta"] = raw.get("meta")
12351238
if raw.get("error"):
12361239
err = raw["error"]
12371240
if isinstance(err, dict):

docs/ja/plugins/sdk-reference.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ self.register_static_ui("static") # <plugin_dir>/static/index.html を配信
130130

131131
タスク完了をホストに通知します。
132132

133+
### 返信制御
134+
135+
`finish()` メソッドは `reply` パラメータ(デフォルト `True`)を受け付け、プラグインの結果がメインキャラクターの発話をトリガーするかどうかを制御します。
136+
137+
```python
138+
# 通常:キャラクターが結果を報告する
139+
return await self.finish(data={"summary": "完了"}, reply=True)
140+
141+
# サイレント:結果は記録されるがキャラクターは話さない
142+
return await self.finish(data={"summary": "完了"}, reply=False)
143+
```
144+
145+
### LLM 結果フィールドフィルタリング
146+
147+
`@plugin_entry`(静的エントリ)または `register_dynamic_entry()`(動的エントリ)の `llm_result_fields` パラメータを使用して、メイン LLM が参照できる結果フィールドを制御します。リストにないフィールドは LLM プロンプトから除外されますが、タスクレジストリには保存されます。
148+
149+
```python
150+
# 静的エントリ
151+
@plugin_entry(llm_result_fields=["summary"])
152+
async def search(self, query: str):
153+
return await self.finish(data={"summary": "3件の結果", "raw_results": [...]})
154+
155+
# 動的エントリ
156+
self.register_dynamic_entry(
157+
entry_id="my-tool",
158+
handler=handler,
159+
llm_result_fields=["summary"],
160+
)
161+
```
162+
133163
---
134164

135165
## Result 型: Ok / Err

docs/plugins/sdk-reference.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ Push export data to the host.
130130

131131
Signal task completion to the host.
132132

133+
### Reply Control
134+
135+
The `finish()` method accepts a `reply` parameter (default `True`) that controls whether the plugin result triggers the main character to speak.
136+
137+
```python
138+
# Normal: character will announce the result
139+
return await self.finish(data={"summary": "Done"}, reply=True)
140+
141+
# Silent: result is recorded but character stays quiet
142+
return await self.finish(data={"summary": "Done"}, reply=False)
143+
```
144+
145+
### LLM Result Field Filtering
146+
147+
Use `llm_result_fields` on `@plugin_entry` (static entries) or `register_dynamic_entry()` (dynamic entries) to control which fields of the result the main LLM can see. Fields not listed are excluded from the LLM prompt but still stored in the task registry.
148+
149+
```python
150+
# Static entry
151+
@plugin_entry(llm_result_fields=["summary"])
152+
async def search(self, query: str):
153+
return await self.finish(data={"summary": "3 results", "raw_results": [...]})
154+
155+
# Dynamic entry
156+
self.register_dynamic_entry(
157+
entry_id="my-tool",
158+
handler=handler,
159+
llm_result_fields=["summary"],
160+
)
161+
```
162+
133163
---
134164

135165
## Result Types: Ok / Err

docs/zh-CN/plugins/sdk-reference.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ self.register_static_ui("static") # 提供 <plugin_dir>/static/index.html 服
130130

131131
向宿主发送任务完成信号。
132132

133+
### 回复控制
134+
135+
`finish()` 方法接受 `reply` 参数(默认 `True`),用于控制插件结果是否触发角色说话。
136+
137+
```python
138+
# 正常:角色会播报结果
139+
return await self.finish(data={"summary": "完成"}, reply=True)
140+
141+
# 静默:结果会记录但角色不说话
142+
return await self.finish(data={"summary": "完成"}, reply=False)
143+
```
144+
145+
### LLM 结果字段过滤
146+
147+
通过 `@plugin_entry` 装饰器(静态入口)或 `register_dynamic_entry()`(动态入口)的 `llm_result_fields` 参数,控制主 LLM 能看到结果中的哪些字段。未列出的字段不会出现在 LLM 提示中,但仍保存在任务注册表中。
148+
149+
```python
150+
# 静态入口
151+
@plugin_entry(llm_result_fields=["summary"])
152+
async def search(self, query: str):
153+
return await self.finish(data={"summary": "找到3条结果", "raw_results": [...]})
154+
155+
# 动态入口
156+
self.register_dynamic_entry(
157+
entry_id="my-tool",
158+
handler=handler,
159+
llm_result_fields=["summary"],
160+
)
161+
```
162+
133163
---
134164

135165
## Result 类型:Ok / Err

plugin/PLUGIN_DEVELOPMENT_GUIDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,36 @@ self.register_static_ui("static") # 提供 <plugin_dir>/static/index.html
347347

348348
通知宿主任务完成。
349349

350+
#### 回复控制
351+
352+
`finish()``reply` 参数(默认 `True`)控制是否触发角色说话:
353+
354+
```python
355+
# 正常:角色会播报结果
356+
return await self.finish(data={"summary": "天气晴朗"}, reply=True)
357+
358+
# 静默:结果会记录但角色不说话
359+
return await self.finish(data={"summary": "天气晴朗"}, reply=False)
360+
```
361+
362+
#### LLM 结果字段过滤
363+
364+
通过 `llm_result_fields` 控制主 LLM 能看到结果中的哪些字段:
365+
366+
```python
367+
# 静态入口:在装饰器中声明
368+
@plugin_entry(llm_result_fields=["summary"])
369+
async def search(self, query: str):
370+
return await self.finish(data={"summary": "3条结果", "raw_results": [...]})
371+
372+
# 动态入口:在注册时声明
373+
self.register_dynamic_entry(
374+
entry_id="my-tool",
375+
handler=handler,
376+
llm_result_fields=["summary"],
377+
)
378+
```
379+
350380
### 3.4 Result 类型:Ok / Err
351381

352382
SDK 使用 Rust 风格的 Result 类型进行错误处理,替代传统异常:

plugin/core/communication.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,14 @@ async def _handle_entry_update(self, msg: Dict[str, Any]) -> None:
515515
if not meta_dict:
516516
self.logger.warning("ENTRY_UPDATE register missing meta: {}", msg)
517517
return
518+
ipc_metadata = {
519+
**(meta_dict.get("metadata") if isinstance(meta_dict.get("metadata"), dict) else {}),
520+
"_dynamic": True,
521+
"_registered_via_ipc": True,
522+
}
523+
llm_fields = meta_dict.get("llm_result_fields")
524+
if isinstance(llm_fields, list):
525+
ipc_metadata["llm_result_fields"] = llm_fields
518526
event_meta = EventMeta(
519527
event_type="plugin_entry",
520528
id=entry_id,
@@ -525,11 +533,7 @@ async def _handle_entry_update(self, msg: Dict[str, Any]) -> None:
525533
auto_start=meta_dict.get("auto_start", False),
526534
enabled=meta_dict.get("enabled", True),
527535
dynamic=True,
528-
metadata={
529-
**(meta_dict.get("metadata") if isinstance(meta_dict.get("metadata"), dict) else {}),
530-
"_dynamic": True,
531-
"_registered_via_ipc": True,
532-
},
536+
metadata=ipc_metadata,
533537
)
534538
handler = EventHandler(meta=event_meta, handler=lambda *args, **kwargs: None)
535539
state.register_event_handler(plugin_id, handler)

0 commit comments

Comments
 (0)