Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
164
backend/services/agent/router/chat_route.go
Normal file
164
backend/services/agent/router/chat_route.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||||
)
|
||||
|
||||
var (
|
||||
// chatRouteHeaderRegex 从模型流式输出中解析 SMARTFLOW_ROUTE 控制码头部。
|
||||
//
|
||||
// 格式示例:
|
||||
// <SMARTFLOW_ROUTE nonce="abc" route="execute" rough_build="true" refine="false" reorder="false" thinking="true"/>
|
||||
//
|
||||
// 属性说明:
|
||||
// 1. nonce:防注入校验,必须与调用方传入的 nonce 精确匹配;
|
||||
// 2. route:路由目标(direct_reply / execute / deep_answer / plan);
|
||||
// 3. rough_build:可选,仅 route=execute 时有效,默认 false;
|
||||
// 4. refine:可选,仅 rough_build=true 时有效,默认 false;
|
||||
// 5. reorder:可选,仅 route=execute 时有效,默认 false;
|
||||
// 6. thinking:可选,仅 route=execute 时有效,默认 false。
|
||||
chatRouteHeaderRegex = regexp.MustCompile(
|
||||
`(?is)<\s*SMARTFLOW_ROUTE\b` +
|
||||
`[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?` +
|
||||
`[^>]*\broute\s*=\s*["']?(direct_reply|execute|deep_answer|plan|quick_task)["']?` +
|
||||
`(?:[^>]*\brough_build\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\brefine\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\breorder\s*=\s*["']?(true|false)["']?)?` +
|
||||
`(?:[^>]*\bthinking\s*=\s*["']?(true|false)["']?)?` +
|
||||
`[^>]*/\s*>`)
|
||||
)
|
||||
|
||||
// StreamRouteParser 从 LLM 流式输出中增量提取路由决策。
|
||||
//
|
||||
// 协议约定:模型输出以 SMARTFLOW_ROUTE 控制码标签开头,标签结束后是用户可见内容。
|
||||
// 例如:<SMARTFLOW_ROUTE nonce="abc" route="direct_reply"/>你好!很高兴见到你...
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责从流式 chunk 中提取控制码并解析为 ChatRoutingDecision;
|
||||
// 2. 不负责推送 SSE chunk,不负责决定后续走哪条链路;
|
||||
// 3. 控制码解析失败时标记 fallback,由上层决定降级策略。
|
||||
type StreamRouteParser struct {
|
||||
buf strings.Builder
|
||||
nonce string
|
||||
routeFound bool
|
||||
decision *agentmodel.ChatRoutingDecision
|
||||
}
|
||||
|
||||
// NewStreamRouteParser 创建流式路由解析器。
|
||||
func NewStreamRouteParser(nonce string) *StreamRouteParser {
|
||||
return &StreamRouteParser{
|
||||
nonce: strings.ToLower(strings.TrimSpace(nonce)),
|
||||
}
|
||||
}
|
||||
|
||||
// Feed 写入一段 chunk content。
|
||||
//
|
||||
// 返回值:
|
||||
// - visible:控制码标签之后的内容(用户可见文本);
|
||||
// - routeReady:路由决策是否已确定;
|
||||
// - err:解析错误。
|
||||
//
|
||||
// 调用方应在 routeReady=true 后调用 Decision() 获取路由决策,
|
||||
// 并根据 route 进入对应分支处理 visible 及后续 chunk。
|
||||
func (p *StreamRouteParser) Feed(content string) (visible string, routeReady bool, err error) {
|
||||
if p.routeFound {
|
||||
// 路由已解析,后续 chunk 直接透传。
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
p.buf.WriteString(content)
|
||||
|
||||
text := p.buf.String()
|
||||
match := chatRouteHeaderRegex.FindStringSubmatchIndex(text)
|
||||
if match == nil {
|
||||
// 控制码尚未完整,检查是否应该 fallback。
|
||||
if len(text) > 500 {
|
||||
// 超过 500 字符仍未匹配到控制码 -> fallback 到 plan。
|
||||
p.routeFound = true
|
||||
p.decision = &agentmodel.ChatRoutingDecision{
|
||||
Route: agentmodel.ChatRoutePlan,
|
||||
Raw: text,
|
||||
}
|
||||
return text, true, fmt.Errorf("控制码解析超时,fallback 到 plan")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 提取匹配到的子组。
|
||||
groups := chatRouteHeaderRegex.FindStringSubmatch(text)
|
||||
if len(groups) < 3 {
|
||||
return "", false, fmt.Errorf("控制码正则子组不足: %d", len(groups))
|
||||
}
|
||||
|
||||
// nonce 校验。
|
||||
parsedNonce := strings.ToLower(strings.TrimSpace(groups[1]))
|
||||
if parsedNonce != p.nonce {
|
||||
return "", false, fmt.Errorf("nonce 不匹配: got=%s expected=%s", parsedNonce, p.nonce)
|
||||
}
|
||||
|
||||
// 解析 route。
|
||||
route := agentmodel.ChatRoute(strings.TrimSpace(groups[2]))
|
||||
|
||||
// 解析可选布尔属性(默认 false)。
|
||||
roughBuild := parseOptionalBool(groups, 3)
|
||||
refine := parseOptionalBool(groups, 4)
|
||||
reorder := parseOptionalBool(groups, 5)
|
||||
thinking := parseOptionalBool(groups, 6)
|
||||
|
||||
p.decision = &agentmodel.ChatRoutingDecision{
|
||||
Route: route,
|
||||
NeedsRoughBuild: roughBuild,
|
||||
NeedsRefineAfterRoughBuild: refine,
|
||||
AllowReorder: reorder,
|
||||
Thinking: thinking,
|
||||
Raw: groups[0],
|
||||
}
|
||||
|
||||
// 归一化与校验。
|
||||
if validateErr := p.decision.Validate(); validateErr != nil {
|
||||
// 校验失败 -> fallback 到 plan。
|
||||
p.decision.Route = agentmodel.ChatRoutePlan
|
||||
p.decision.NeedsRoughBuild = false
|
||||
p.decision.NeedsRefineAfterRoughBuild = false
|
||||
p.decision.AllowReorder = false
|
||||
p.decision.Thinking = false
|
||||
}
|
||||
|
||||
p.routeFound = true
|
||||
|
||||
// 控制码标签之后的文本作为 visible 返回。
|
||||
fullMatch := groups[0]
|
||||
tagEndIdx := strings.Index(text, fullMatch)
|
||||
if tagEndIdx >= 0 {
|
||||
afterTag := text[tagEndIdx+len(fullMatch):]
|
||||
// 去掉标签后紧跟的换行符(如果有)。
|
||||
afterTag = strings.TrimPrefix(afterTag, "\r\n")
|
||||
afterTag = strings.TrimPrefix(afterTag, "\n")
|
||||
return afterTag, true, nil
|
||||
}
|
||||
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// RouteReady 返回路由决策是否已确定。
|
||||
func (p *StreamRouteParser) RouteReady() bool {
|
||||
return p.routeFound
|
||||
}
|
||||
|
||||
// Decision 返回已解析的路由决策(RouteReady=true 后可用)。
|
||||
func (p *StreamRouteParser) Decision() *agentmodel.ChatRoutingDecision {
|
||||
return p.decision
|
||||
}
|
||||
|
||||
// parseOptionalBool 从正则子组中解析可选布尔值。
|
||||
// 如果子组不存在或为空,返回 false。
|
||||
func parseOptionalBool(groups []string, index int) bool {
|
||||
if index >= len(groups) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(groups[index]) == "true"
|
||||
}
|
||||
227
backend/services/agent/router/decision_parser.go
Normal file
227
backend/services/agent/router/decision_parser.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package agentrouter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// decisionTagRegex 从模型流式输出中提取 <SMARTFLOW_DECISION>...</SMARTFLOW_DECISION> 标签。
|
||||
//
|
||||
// 格式示例:
|
||||
// <SMARTFLOW_DECISION>{"action":"continue","reason":"...","tool_call":{...}}</SMARTFLOW_DECISION>
|
||||
// 用户可见的友好文案在这里流式输出...
|
||||
//
|
||||
// 使用 (?s) dotall 模式使 . 匹配换行符(JSON 可能包含换行),
|
||||
// 非贪婪 (.*?) 避免匹配到多个标签时过度消耗。
|
||||
decisionTagRegex = regexp.MustCompile(
|
||||
`(?s)<\s*SMARTFLOW_DECISION\s*>(.*?)</\s*SMARTFLOW_DECISION\s*>`)
|
||||
// decisionTagHeadRegex 仅用于识别“起始标签是否已经出现”。
|
||||
// 目的:避免模型已经输出了 <SMARTFLOW_DECISION 开头但尚未输出闭合标签时,
|
||||
// 被长度阈值误判为 fallback(即“假截断”)。
|
||||
decisionTagHeadRegex = regexp.MustCompile(`(?i)<\s*SMARTFLOW_DECISION\b`)
|
||||
)
|
||||
|
||||
// StreamDecisionResult 描述解析器的最终输出状态。
|
||||
type StreamDecisionResult struct {
|
||||
// DecisionJSON 是标签内提取的完整 JSON 字符串。
|
||||
// 调用方应使用 llmservice.ParseJSONObject[T] 将其解析为具体决策类型。
|
||||
DecisionJSON string
|
||||
|
||||
// BeforeText 是 <SMARTFLOW_DECISION> 标签之前的自然语言前言。
|
||||
// 仅用于“标签后正文为空”时的兜底展示,不参与 JSON 解析。
|
||||
BeforeText string
|
||||
|
||||
// AfterText 是 </SMARTFLOW_DECISION> 标签之后的自然语言正文。
|
||||
// 这是主协议约定的用户可见文本来源。
|
||||
AfterText string
|
||||
|
||||
// Fallback=true 表示流中未找到决策标签(超过 500 字符阈值),
|
||||
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
|
||||
Fallback bool
|
||||
|
||||
// ParseFailed=true 表示找到了标签但内部 JSON 为空或括号计数提取失败,
|
||||
// RawBuffer 包含全部累积文本,调用方应走 correction 路径。
|
||||
ParseFailed bool
|
||||
|
||||
// RawBuffer 是流式累积的原始文本,用于 correction / 日志。
|
||||
RawBuffer string
|
||||
}
|
||||
|
||||
// StreamDecisionParser 从 LLM 流式输出中增量提取 <SMARTFLOW_DECISION> 标签内的 JSON。
|
||||
//
|
||||
// 协议约定:模型先输出 <SMARTFLOW_DECISION>{json}</SMARTFLOW_DECISION>,然后输出用户可见正文。
|
||||
// 调用方在 ready=true 后通过 DecisionJSON() 获取 JSON 字符串并自行解析,
|
||||
// 同一个 StreamReader 继续读取标签后的正文逐 token 推流。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责从流式 chunk 中提取决策标签和 JSON 字符串;
|
||||
// 2. 不负责 JSON 反序列化(由调用方用 ParseJSONObject 完成);
|
||||
// 3. 不负责推送 SSE chunk。
|
||||
type StreamDecisionParser struct {
|
||||
buf strings.Builder
|
||||
decisionFound bool
|
||||
decisionJSON string
|
||||
beforeText string
|
||||
afterText string
|
||||
rawBuf string // 用于 fallback/correction
|
||||
}
|
||||
|
||||
// NewStreamDecisionParser 创建决策标签流式解析器。
|
||||
func NewStreamDecisionParser() *StreamDecisionParser {
|
||||
return &StreamDecisionParser{}
|
||||
}
|
||||
|
||||
// Feed 写入一段 chunk content。
|
||||
//
|
||||
// 返回值:
|
||||
// - visible:决策标签之后的文本(用户可见内容,可能为空);
|
||||
// - ready:决策是否已提取完毕(成功或 fallback);
|
||||
// - err:非 nil 时表示 fallback 或解析失败。
|
||||
//
|
||||
// 调用方应在 ready=true 后:
|
||||
// 1. 调用 Result() 获取解析结果;
|
||||
// 2. 若 Fallback/ParseFailed 则走 correction 路径;
|
||||
// 3. 否则用 DecisionJSON 解析为具体决策类型;
|
||||
// 4. 继续读取同一个 reader,逐 token 推流 visible 及后续 chunk。
|
||||
func (p *StreamDecisionParser) Feed(content string) (visible string, ready bool, err error) {
|
||||
if p.decisionFound {
|
||||
return content, true, nil
|
||||
}
|
||||
|
||||
p.buf.WriteString(content)
|
||||
|
||||
text := p.buf.String()
|
||||
match := decisionTagRegex.FindStringSubmatchIndex(text)
|
||||
if match == nil {
|
||||
// 1. 标签尚未完整,检查 fallback 阈值。
|
||||
// 2. 仅当“完全没有出现起始标签”时才允许 fallback。
|
||||
// 3. 若已经出现起始标签但还没闭合,则继续等待后续 chunk,避免早退。
|
||||
if len(text) > 500 {
|
||||
if decisionTagHeadRegex.MatchString(text) {
|
||||
return "", false, nil
|
||||
}
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return text, true, fmt.Errorf("决策标签解析超时,未找到 SMARTFLOW_DECISION 标签")
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 提取标签内文本(子组 1)。
|
||||
groups := decisionTagRegex.FindStringSubmatch(text)
|
||||
if len(groups) < 2 {
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return "", true, fmt.Errorf("决策标签正则子组不足")
|
||||
}
|
||||
|
||||
inner := groups[1]
|
||||
jsonStr := extractJSONFromTag(inner)
|
||||
if jsonStr == "" {
|
||||
p.decisionFound = true
|
||||
p.rawBuf = text
|
||||
return "", true, fmt.Errorf("决策标签内未找到有效 JSON")
|
||||
}
|
||||
|
||||
p.decisionFound = true
|
||||
p.decisionJSON = jsonStr
|
||||
p.rawBuf = text
|
||||
|
||||
// 1. 同时提取标签前/标签后的自然语言片段。
|
||||
// 2. 标签后正文仍然作为主协议 visible 返回,保持现有流式链路不变。
|
||||
// 3. 标签前前言只记入 Result,供 execute 在“后文为空”时兜底补发。
|
||||
fullMatch := groups[0]
|
||||
tagEndIdx := strings.Index(text, fullMatch)
|
||||
if tagEndIdx >= 0 {
|
||||
beforeTag := strings.TrimSpace(text[:tagEndIdx])
|
||||
afterTag := text[tagEndIdx+len(fullMatch):]
|
||||
afterTag = strings.TrimPrefix(afterTag, "\r\n")
|
||||
afterTag = strings.TrimPrefix(afterTag, "\n")
|
||||
p.beforeText = beforeTag
|
||||
p.afterText = afterTag
|
||||
return afterTag, true, nil
|
||||
}
|
||||
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
// Ready 返回决策是否已提取完毕。
|
||||
func (p *StreamDecisionParser) Ready() bool {
|
||||
return p.decisionFound
|
||||
}
|
||||
|
||||
// DecisionJSON 返回标签内提取的 JSON 字符串。
|
||||
// 仅在 Ready()=true 且 Result().Fallback=false && Result().ParseFailed=false 时有效。
|
||||
func (p *StreamDecisionParser) DecisionJSON() string {
|
||||
return p.decisionJSON
|
||||
}
|
||||
|
||||
// Result 返回完整解析结果,包含 fallback/parseFailed 状态和原始缓冲。
|
||||
func (p *StreamDecisionParser) Result() *StreamDecisionResult {
|
||||
r := &StreamDecisionResult{
|
||||
DecisionJSON: p.decisionJSON,
|
||||
BeforeText: p.beforeText,
|
||||
AfterText: p.afterText,
|
||||
RawBuffer: p.rawBuf,
|
||||
}
|
||||
if p.rawBuf != "" && p.decisionJSON == "" {
|
||||
// 没有提取到 JSON:判断是 fallback 还是 parseFailed。
|
||||
// fallback = buf 里根本没有标签;parseFailed = 有标签但 JSON 提取失败。
|
||||
if decisionTagRegex.FindStringSubmatchIndex(p.rawBuf) != nil {
|
||||
r.ParseFailed = true
|
||||
} else {
|
||||
r.Fallback = true
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// extractJSONFromTag 从标签内文本中提取第一个完整 JSON 对象。
|
||||
// 复用括号计数逻辑,与 llmservice.ExtractJSONObject 一致。
|
||||
func extractJSONFromTag(text string) string {
|
||||
clean := strings.TrimSpace(text)
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := strings.Index(clean, "{")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for idx := start; idx < len(clean); idx++ {
|
||||
ch := clean[idx]
|
||||
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' && inString {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return clean[start : idx+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user