Files
sundynix-agentix/sundynix-dispatcher/internal/eino/react_agent.go
T
Blizzard 79437e1c1c feat(mcp-py,dispatcher): 自主 agent 工具动态发现覆盖 mcp-py
把动态工具发现从 mcp-go 扩到两台 MCP:
- mcp-py:TOOL_META 改成与 mcp-go 同形(cn/desc/agent/params/inject),
  list_tools 上报这些;run_code 标 agent 可用(params: code)→ 自主 agent
  可执行 Python 代码做计算/数据处理。
- dispatcher:agentTools 拆出 discoverTools(subject),分别探 mcp-go / mcp-py
  的 list_tools 并合并;某台 MCP 离线即跳过(降级,不阻断)。

验收:mcp-py 未启动时自主 agent 仍正常(探测秒跳过),一次自主调用了
history_get + memory_get 两个动态发现的 go 工具(注入 session_id/user_id);
go 全绿、mcp-py py_compile 通过。mcp-py 起着时 run_code 即自动入 agent 菜单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:34:07 +08:00

234 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"time"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-shared/contract"
)
// reactMaxStep 限制 ReAct 推理-工具循环的步数上限(控成本/时延)。
const reactMaxStep = 8
// streamHasToolCall 扫描模型整段流输出,任一片段含 tool call 即判定为工具调用。
// 比默认"只看首片段"鲁棒(兼容先吐文本/思考再给 tool call 的模型,如 deepseek)。
// 契约要求:返回前必须 Close 传入的流。
func streamHasToolCall(_ context.Context, sr *schema.StreamReader[*schema.Message]) (bool, error) {
defer sr.Close()
for {
msg, err := sr.Recv()
if err == io.EOF {
return false, nil
}
if err != nil {
return false, err
}
if len(msg.ToolCalls) > 0 {
return true, nil
}
}
}
// mcpTool 把一个 MCP 工具(NATS 那头)适配成 Eino InvokableTool
// 模型给的参数(JSON) + 运行时注入的绑定参数(uid/kb,不暴露给模型) 合并后经 NATS 调 MCP。
type mcpTool struct {
info *schema.ToolInfo
mcpName string // MCP 侧真实工具名(可与 info.Name 不同)
subject func(string) string // contract.ToolSubjectGo/Py
bind map[string]any // 运行时注入参数
caller ToolCaller
taskID string
tr *execTracer
}
func (m *mcpTool) Info(_ context.Context) (*schema.ToolInfo, error) { return m.info, nil }
// InvokableRun 执行一次工具调用:合并参数 → NATS 调 MCP → 返回观察给模型;
// 调用本身落一条 ExecEvent 轨迹("模型自己调了哪个工具"在观测里可见)。
func (m *mcpTool) InvokableRun(ctx context.Context, argsJSON string, _ ...tool.Option) (string, error) {
args := map[string]any{}
if argsJSON != "" {
_ = json.Unmarshal([]byte(argsJSON), &args)
}
for k, v := range m.bind {
args[k] = v
}
log.Printf("[react] 模型自主调用工具 %s (mcp=%s) task=%s args=%s", m.info.Name, m.mcpName, m.taskID, truncate(argsJSON, 120))
end := m.tr.span("tool:"+m.mcpName, "tool", "模型自主调用 "+m.info.Name)
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
res, err := m.caller.CallTool(cctx, m.subject(m.mcpName), &contract.ToolCall{Tool: m.mcpName, TaskID: m.taskID, Args: args})
if err != nil {
end("调用失败", err)
return "工具调用失败:" + err.Error(), nil // 作为观察返回,不中断 ReAct
}
if res == nil || !res.OK {
msg := "工具无结果"
if res != nil && res.Error != "" {
msg = res.Error
}
end(msg, nil)
return msg, nil
}
end("入参 "+truncate(argsJSON, 120)+" → "+truncate(res.Content, 160), nil)
return res.Content, nil
}
// toolCatalogEntry 是 MCP list_tools 上报的一条工具元信息(与 mcp-go listTools 输出对齐)。
type toolCatalogEntry struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"`
AgentName string `json:"agent_name"`
Params []struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Required bool `json:"required"`
} `json:"params"`
Inject []string `json:"inject"`
}
// agentTools 动态构建 ReAct 可用的工具集:分别向 mcp-go / mcp-py 探 list_tools 自描述目录,
// 取 agent_exposed 的工具按上报参数 schema 建 InvokableToolinject 参数(user_id/session_id/
// kb/task_id)服务端运行时绑定、不暴露给模型。某台 MCP 离线即跳过(降级)。
// 新增工具只需在对应 MCP 注册表标 agent,无需改这里(杜绝硬编码)。
func (o *Orchestrator) agentTools(b *board, taskID string, tr *execTracer) []tool.BaseTool {
if o.tools == nil {
return nil
}
var out []tool.BaseTool
out = append(out, o.discoverTools(contract.ToolSubjectGo, b, taskID, tr)...)
out = append(out, o.discoverTools(contract.ToolSubjectPy, b, taskID, tr)...)
return out
}
// discoverTools 向某台 MCPsubject 前缀决定 go/py)探 list_tools,把 agent_exposed 的工具
// 转成 Eino InvokableTool。该 MCP 不可用 / 无应答时返回空(不阻断)。
func (o *Orchestrator) discoverTools(subject func(string) string, b *board, taskID string, tr *execTracer) []tool.BaseTool {
cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout)
defer cancel()
res, err := o.tools.CallTool(cctx, subject("list_tools"), &contract.ToolCall{Tool: "list_tools"})
if err != nil || res == nil || !res.OK {
return nil
}
var cat struct {
Tools []toolCatalogEntry `json:"tools"`
}
if json.Unmarshal([]byte(res.Content), &cat) != nil {
return nil
}
injectVal := map[string]any{"user_id": b.uid, "session_id": b.sid, "task_id": taskID, "kb": b.kb}
var out []tool.BaseTool
for _, e := range cat.Tools {
if !e.Agent {
continue
}
params := map[string]*schema.ParameterInfo{}
for _, p := range e.Params {
params[p.Name] = &schema.ParameterInfo{Type: schema.DataType(p.Type), Desc: p.Desc, Required: p.Required}
}
bind := map[string]any{}
for _, inj := range e.Inject {
if v, ok := injectVal[inj]; ok && v != "" {
bind[inj] = v
}
}
name := e.AgentName
if name == "" {
name = e.Name
}
out = append(out, &mcpTool{
mcpName: e.Name,
subject: subject,
caller: o.tools, taskID: taskID, tr: tr,
bind: bind,
info: &schema.ToolInfo{
Name: name, Desc: e.Desc,
ParamsOneOf: schema.NewParamsOneOfByParams(params),
},
})
}
return out
}
// runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。
// 模型不支持函数调用 / 无工具时降级回普通 runAgent。最终答复流式回流;工具调用由适配器落轨迹。
func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
tcm := o.pool.ToolCallingModel()
tools := o.agentTools(b, taskID, tr)
if tcm == nil || len(tools) == 0 {
tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话")
o.runAgent(ctx, taskID, b, system, tr, node)
return
}
ag, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: tcm,
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
MaxStep: reactMaxStep,
// 默认检查器只看首个流片段;deepseek 等常先吐文本再给 tool call → 漏判。
// 改成扫描整段流,任一片段含 tool call 即判定为工具调用。
StreamToolCallChecker: streamHasToolCall,
})
if err != nil {
tr.emit(node, "model", "error", "构建 ReAct 智能体", err.Error(), 0)
o.runAgent(ctx, taskID, b, system, tr, node)
return
}
rc := &RunCtx{
UserID: b.uid, SessionID: b.sid,
System: firstNonEmpty(system, defaultAgentSystem),
Query: b.query,
// 自主 agent 不预注入画像:让它经 recall_user_memory 工具按需自取(否则模型直接答、不调工具)。
Profile: "",
History: b.history,
ToolOut: append(append([]string{}, b.toolOut...), b.refs...),
}
msgs, _ := buildMessages(ctx, rc)
tr.emit(node, "model", "start", "ReAct 智能体(自主调工具)", fmt.Sprintf("%d 个工具可用", len(tools)), 0)
t0 := time.Now()
sr, err := ag.Stream(ctx, msgs)
if err != nil {
tr.emit(node, "model", "error", "ReAct 智能体", err.Error(), time.Since(t0).Milliseconds())
return
}
defer sr.Close()
chunks := 0
for {
chunk, rerr := sr.Recv()
if rerr == io.EOF {
break
}
if rerr != nil {
tr.emit(node, "model", "error", "ReAct 智能体", rerr.Error(), time.Since(t0).Milliseconds())
return
}
if chunk.Content == "" {
continue // 工具调用片段无正文,跳过;正文只来自模型答复
}
safe, _ := harness.RedactSecrets(chunk.Content)
_ = o.sink.PublishToken(taskID, []byte(safe))
b.answer += safe
chunks++
}
tr.emit(node, "model", "end", "ReAct 智能体",
fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(b.answer))), time.Since(t0).Milliseconds())
}