feat: 修复工具返回异常的问题

This commit is contained in:
Wenjie Zhang 2026-06-03 14:09:31 +08:00
parent 206ddd8ff0
commit eb55780d0d
4 changed files with 122 additions and 4 deletions

View File

@ -30,11 +30,11 @@
- [x] 子智能体的优化,参考 PR 的方案。 - [x] 子智能体的优化,参考 PR 的方案。
- [x] 附件上传能够支持转换为 PDF待办查看 OCR 模型的状态,样式优化,保存的文件名不对 - [x] 附件上传能够支持转换为 PDF待办查看 OCR 模型的状态,样式优化,保存的文件名不对
- [x] 参考 PR实现内置 Dashscope 的 Embedding 和 rerank 的方法 - [x] 参考 PR实现内置 Dashscope 的 Embedding 和 rerank 的方法
- [ ] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口 - [x] 优化知识库的 API 接口设计,使用 /{db_id}/xxx 的形式,整合 mindmap / eval 接口
- [x] 前端新增基于 ID 的随机像素头像生成机制(类似 GitHub 方块头像),用于替代默认图片 - [x] 前端新增基于 ID 的随机像素头像生成机制(类似 GitHub 方块头像),用于替代默认图片
- [x] 子智能体右上角功能增强任务按钮常驻展开后展示已激活子智能体、附件、state 等信息 - [x] 子智能体右上角功能增强任务按钮常驻展开后展示已激活子智能体、附件、state 等信息
- [ ] 流式输出混排修复ongoing 流式返回时按 thread ID 区分主/子智能体内容,子智能体内容单独可视化渲染 - [x] 流式输出混排修复ongoing 流式返回时按 thread ID 区分主/子智能体内容,子智能体内容单独可视化渲染
- [ ] 子智能体中间过程可查看:流式结束后,通过子智能体自身 thread ID 获取并渲染中间调用过程 - [x] 子智能体中间过程可查看:流式结束后,通过子智能体自身 thread ID 获取并渲染中间调用过程
- [x] allow multi-hop qa generate - [x] allow multi-hop qa generate
- [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面 - [x] 在工作区的文件编辑的时候,保存和取消的按钮应该是悬浮在编辑框的右上角,而不是在 header 上面
- [x] default enable all build in tools / kbs / skills / mcps / subagents - [x] default enable all build in tools / kbs / skills / mcps / subagents
@ -44,7 +44,7 @@
- [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能 - [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能
- [x] 智能体体系改进。改进子智能体,我觉得子智能体并不是一个子级的智能体,应该是 - [x] 智能体体系改进。改进子智能体,我觉得子智能体并不是一个子级的智能体,应该是
- [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示 - [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示
- [ ] 检查前端的这些 store 的使用,是否有需要调整优化收敛的地方 - [x] 检查前端的这些 store 的使用,是否有需要调整优化收敛的地方
- [x] parser 从plugins 移动到 knowledge 里面guard 移动到services 里面 - [x] parser 从plugins 移动到 knowledge 里面guard 移动到services 里面
- [x] neo4j 相关的服务,可以移动到 storage 里面 - [x] neo4j 相关的服务,可以移动到 storage 里面
- [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。 - [ ] 点开对话的时候要能够自动定位到尾部,而不是最开始。

View File

@ -7,9 +7,11 @@ from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from langchain_core.messages import ToolMessage
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver, aiosqlite from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver, aiosqlite
from langgraph.graph.state import CompiledStateGraph from langgraph.graph.state import CompiledStateGraph
from langgraph.types import Command
from yuxi import config as sys_config from yuxi import config as sys_config
from yuxi.agents.context import BaseContext, resolve_agent_resource_options from yuxi.agents.context import BaseContext, resolve_agent_resource_options
@ -30,6 +32,29 @@ def _json_safe(value: Any) -> Any:
return str(value) return str(value)
def _normalize_tool_event_data(data: Any) -> Any:
"""规整 tools 流事件write_todos / task 等返回 Command 的工具,其 tool-finished
output Command 对象_json_safe 只能退化成 repr 字符串前端无法关联结果
这里从 Command.update["messages"] 取出真正的 ToolMessage使其与普通工具一致"""
if not isinstance(data, dict) or data.get("event") != "tool-finished":
return data
output = data.get("output")
if not isinstance(output, Command):
return data
update = output.update if isinstance(output.update, dict) else {}
messages = update.get("messages")
if not isinstance(messages, list):
return data
tool_call_id = data.get("tool_call_id")
tool_message = next(
(m for m in messages if isinstance(m, ToolMessage) and m.tool_call_id == tool_call_id),
next((m for m in messages if isinstance(m, ToolMessage)), None),
)
if tool_message is None:
return data
return {**data, "output": tool_message}
def _metadata_thread_id(value: Any) -> str | None: def _metadata_thread_id(value: Any) -> str | None:
if not isinstance(value, dict): if not isinstance(value, dict):
return None return None
@ -235,6 +260,8 @@ class BaseAgent:
elif method == "values" and not namespace: elif method == "values" and not namespace:
yield "values", data yield "values", data
elif method in {"tasks", "tools", "lifecycle"}: elif method in {"tasks", "tools", "lifecycle"}:
if method == "tools":
data = _normalize_tool_event_data(data)
event_payload = { event_payload = {
"method": method, "method": method,
"namespace": namespace, "namespace": namespace,

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from langchain_core.messages import ToolMessage
from langgraph.types import Command
from yuxi.agents.base import _json_safe, _normalize_tool_event_data
def _command_tool_finished(tool_call_id: str) -> dict:
"""模拟 write_todos / task 这类返回 Command 的工具的 tool-finished 事件。"""
tool_message = ToolMessage(
content="Updated todo list to [{'content': '步骤一', 'status': 'in_progress'}]",
tool_call_id=tool_call_id,
)
command = Command(update={"todos": [{"content": "步骤一", "status": "in_progress"}], "messages": [tool_message]})
return {"event": "tool-finished", "tool_call_id": tool_call_id, "output": command}
def test_command_tool_finished_extracts_tool_message_for_frontend_association():
tool_call_id = "call_abc"
data = _normalize_tool_event_data(_command_tool_finished(tool_call_id))
safe = _json_safe(data)
output = safe["output"]
# 前端按 tool_call_id 关联结果,并要求 output 是对象dict否则会被丢弃。
assert isinstance(output, dict)
assert output["tool_call_id"] == tool_call_id
assert output["type"] == "tool"
assert "步骤一" in output["content"]
def test_command_tool_finished_prefers_message_matching_tool_call_id():
other = ToolMessage(content="别的工具结果", tool_call_id="call_other")
target = ToolMessage(content="目标结果", tool_call_id="call_target")
data = {
"event": "tool-finished",
"tool_call_id": "call_target",
"output": Command(update={"messages": [other, target]}),
}
output = _normalize_tool_event_data(data)["output"]
assert isinstance(output, ToolMessage)
assert output.tool_call_id == "call_target"
assert output.content == "目标结果"
def test_regular_dict_output_is_left_untouched():
data = {"event": "tool-finished", "tool_call_id": "call_x", "output": {"content": "plain", "type": "tool"}}
assert _normalize_tool_event_data(data)["output"] == {"content": "plain", "type": "tool"}
def test_tool_started_event_is_left_untouched():
data = {"event": "tool-started", "tool_call_id": "call_x", "output": None}
assert _normalize_tool_event_data(data) is data
def test_command_without_tool_message_is_left_untouched():
command = Command(update={"todos": [{"content": "无消息", "status": "pending"}]})
data = {"event": "tool-finished", "tool_call_id": "call_x", "output": command}
assert _normalize_tool_event_data(data)["output"] is command

View File

@ -59,6 +59,23 @@ const loadingMessageChunk = (chunk) => {
return msg || null return msg || null
} }
// 工具结果不走 messages 流,而是以 method=tools 的 stream_event 事件返回tool-started/tool-finished
// 取出 tool-finished 的 output一条 ToolMessage 字典),交给 msgChunks 与 AI 消息按 tool_call_id 关联。
const toolFinishedMessage = (chunk) => {
const streamEvent = chunk?.event
if (!streamEvent || streamEvent.method !== 'tools') return null
const data = streamEvent.data
if (!data || data.event !== 'tool-finished') return null
const output = data.output
if (!output || typeof output !== 'object') return null
const id = output.id || output.tool_call_id || data.tool_call_id
if (!id) return null
return { ...output, type: 'tool', id }
}
export function useAgentStreamHandler({ export function useAgentStreamHandler({
getThreadState, getThreadState,
processApprovalInStream, processApprovalInStream,
@ -119,6 +136,20 @@ export function useAgentStreamHandler({
} }
return false return false
case 'stream_event':
{
// 工具结果需立即落地(不经平滑层),写入 msgChunks 后由 convertToolResultToMessages
// 按 tool_call_id 关联到对应 AI 消息的 tool_call驱动其完成态。
const toolMessage = toolFinishedMessage(chunk)
if (toolMessage) {
if (!threadState.onGoingConv.msgChunks[toolMessage.id]) {
threadState.onGoingConv.msgChunks[toolMessage.id] = []
}
threadState.onGoingConv.msgChunks[toolMessage.id].push(toolMessage)
}
}
return false
case 'error': case 'error':
streamSmoother?.flushThread(threadId) streamSmoother?.flushThread(threadId)
handleChatError({ message: chunkMessage }, 'stream') handleChatError({ message: chunkMessage }, 'stream')