Files
sundynix-agentix/sundynix-dispatcher/internal/eino/react_agent.go
T
Blizzard 9506a82be9 feat(harness): 输出脱敏增强 —— 跨分片 StreamRedactor + PII,杜绝密钥碎片泄漏
原逐片脱敏有两个漏:①密钥被切成两片("sk-912cf85b"|"16d0...")逐片都不命中正则而漏检;
②贪婪正则在缓冲末尾凑够最短长度就把半截密钥提前脱敏发走、剩余字符随后明文流出(碎片泄漏)。

有状态 StreamRedactor 跨分片缓冲,切点在「原文」上定且绝不切断任何完整匹配:
- opener 暂留末尾仍在增长的疑似密钥(sk/AKIA/JWT/Bearer/手机/邮箱/长数字)
- 始终留 16B 尾窗兜底 opener 未覆盖的短模式;勿切断完整匹配(循环至稳定)
- rune 边界安全:cut 退到最近 rune 起点,中文不被切成半个发出乱码
- 暂留封顶 256B,防对抗性长串无限暂留 / O(n²)
- 新增 PII:手机号 / 邮箱 / 身份证(18 位)

3 个流式点(graph/react_agent/compose_graph)统一接入,逐片 Push + 收尾 Flush。
7 单测(跨片/逐字符 JWT/碎片回归/尾窗内匹配/干净重建/PII/无误伤),-race 干净。
live 实测:26 位密钥(曾泄漏 ijkl90mnop 碎片)与邮箱整条 [已脱敏]。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:46:08 +08:00

245 lines
8.6 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"
"strings"
"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...),
Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力
}
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
var produced strings.Builder // 本节点产出(供下游 agent 接力)
red := harness.NewStreamRedactor() // 输出护栏:跨分片脱敏,杜绝密钥被切断而漏检
emit := func(safe string) {
if safe == "" {
return
}
_ = o.sink.PublishToken(taskID, []byte(safe))
produced.WriteString(safe)
chunks++
}
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 // 工具调用片段无正文,跳过;正文只来自模型答复
}
emit(red.Push(chunk.Content))
}
emit(red.Flush()) // 吐出暂留尾部
o.recordAgentOutput(b, produced.String())
tr.emit(node, "model", "end", "ReAct 智能体",
fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(produced.String()))), time.Since(t0).Milliseconds())
}