diff --git a/design/vue/src/composables/useChat.ts b/design/vue/src/composables/useChat.ts index ce4e586..76cb17f 100644 --- a/design/vue/src/composables/useChat.ts +++ b/design/vue/src/composables/useChat.ts @@ -105,7 +105,7 @@ export function useChat() { // ── 中断控制 ── let abortController: AbortController | null = null - // ── 审批状态(危险工具中断) ── + // ── 审批/澄清状态(统一处理 interrupt) ── const pendingApproval = ref(null) // interrupt 事件所在的 assistant 消息块 ID,恢复流程复用同一个 let interruptedAssistantId = '' @@ -246,7 +246,7 @@ export function useChat() { loadMessages(sessionId) } - // 切换会话时恢复/清除审批卡状态 + // 切换会话时恢复/清除审批卡或澄清追问卡状态 watch( () => activeSession.value, (sess) => { @@ -255,9 +255,11 @@ export function useChat() { pendingApproval.value = { checkpoint_id: pc.checkpoint_id, interrupt_id: pc.interrupt_id, - title: '需要人工确认', - detail: pc.question ?? '执行被中断,等待用户审批', + title: pc.is_clarify ? '需要澄清' : '需要人工确认', + detail: pc.question ?? '执行被中断', tool_name: pc.tool_name, + options: pc.options, + is_clarify: pc.is_clarify ?? false, } } else { pendingApproval.value = null @@ -459,34 +461,38 @@ export function useChat() { progressText.value = '' streamContent.value = '' streamSources.value = [] - // streamTimeline 不清空,interrupt 前的步骤保留,恢复后继续累加 - const info = evt.interrupt_info ?? {} - const approval: PendingApproval = { - checkpoint_id: evt.checkpoint_id ?? '', - interrupt_id: evt.interrupt_id ?? '', - title: '需要人工确认', - detail: evt.detail ?? (info?.message as string) ?? '执行被中断,等待用户处理', - tool_name: (info?.tool_name as string) ?? '', - target_ref: (info?.target_ref as string) ?? '', - reason: (info?.reason as string) ?? '', - } - pendingApproval.value = approval - // 记录 assistant 块 ID,恢复时 done 事件复用同一块 interruptedAssistantId = assistantId || 'a-' + Date.now() - return - } - case 'clarify': { - isLoading.value = false - progressText.value = '' - const q = evt.clarify?.question ?? evt.detail ?? '' - const opts = evt.clarify?.options ?? [] - messages.value.push({ - id: 'c-' + Date.now(), - role: 'assistant', - content: q, - }) - break + if (evt.status === 'clarify' || evt.clarify) { + // 澄清追问 + const approval: PendingApproval = { + checkpoint_id: evt.checkpoint_id ?? '', + interrupt_id: evt.interrupt_id ?? '', + title: '需要澄清', + detail: evt.clarify?.question ?? evt.detail ?? '', + tool_name: '', + target_ref: '', + reason: evt.clarify?.context ?? '', + options: evt.clarify?.options ?? [], + is_clarify: true, + } + pendingApproval.value = approval + } else { + // 危险工具审批 + const info = evt.interrupt_info ?? {} + const approval: PendingApproval = { + checkpoint_id: evt.checkpoint_id ?? '', + interrupt_id: evt.interrupt_id ?? '', + title: '需要人工确认', + detail: evt.detail ?? (info?.message as string) ?? '执行被中断,等待用户处理', + tool_name: (info?.tool_name as string) ?? '', + target_ref: (info?.target_ref as string) ?? '', + reason: (info?.reason as string) ?? '', + is_clarify: false, + } + pendingApproval.value = approval + } + return } case 'done': @@ -647,12 +653,12 @@ export function useChat() { } } - // ── 危险工具审批 ── - function approvePending(resolution: 'approve' | 'reject') { + // ── 审批 / 澄清追问统一入口 ── + function approvePending(resolution: string) { if (!pendingApproval.value) return - input.value = resolution // 请求内容 + input.value = resolution pendingApproval.value = null - void sendMessage(undefined, true) // isResume=true: 不 push 用户气泡 + void sendMessage(undefined, true) } function cancelApproval() { diff --git a/design/vue/src/pages/ChatPage.vue b/design/vue/src/pages/ChatPage.vue index 0790149..709fbee 100644 --- a/design/vue/src/pages/ChatPage.vue +++ b/design/vue/src/pages/ChatPage.vue @@ -171,9 +171,53 @@ - +
-
+ +
+
+
+ + + + 需要澄清 +
+
+

{{ pendingApproval.detail }}

+ +
+ +
+ +
+
+ + +
+
+ +
+
+
+ +
@@ -366,6 +410,15 @@ const { const chatEl = ref() const hasMessages = computed(() => messages.value.length > 0) +// ── 澄清追问自由输入 ── +const clarifyInput = ref('') +function submitClarify() { + const v = clarifyInput.value.trim() + if (!v) return + approvePending(v) + clarifyInput.value = '' +} + useMarkdownTooltip() // ── 保存笔记到知识库 ── diff --git a/design/vue/src/types/chat.ts b/design/vue/src/types/chat.ts index 84fc153..9485492 100644 --- a/design/vue/src/types/chat.ts +++ b/design/vue/src/types/chat.ts @@ -5,6 +5,8 @@ export interface PendingCheckpointInfo { interrupt_id: string question?: string tool_name?: string + is_clarify?: boolean + options?: string[] set_at: string } @@ -114,6 +116,8 @@ export interface PendingApproval { tool_name?: string target_ref?: string reason?: string + options?: string[] + is_clarify?: boolean } // ── List Responses ── diff --git a/internal/agent/engine.go b/internal/agent/engine.go index b4a924c..89bf433 100644 --- a/internal/agent/engine.go +++ b/internal/agent/engine.go @@ -87,3 +87,13 @@ func (e *Engine) dangerousToolNames() map[string]bool { } return m } + +func (e *Engine) clarifyToolNames() map[string]bool { + m := make(map[string]bool) + for _, entry := range e.internalTools { + if entry.Name == "ask_clarify" { + m[entry.Name] = true + } + } + return m +} diff --git a/internal/agent/execute.go b/internal/agent/execute.go index 1ea831e..d9f2e55 100644 --- a/internal/agent/execute.go +++ b/internal/agent/execute.go @@ -163,12 +163,18 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool return fmt.Sprintf("⚠️ 工具 %q 不存在,可用工具请查看系统提示。请检查工具名拼写后重试。", name), nil }, } - // 有危险工具时注入审批中间件 + // ── 注入中间件:危险工具审批 + 澄清追问 ── + var middlewares []compose.ToolMiddleware if dangerousNames := e.dangerousToolNames(); len(dangerousNames) > 0 { - toolsNodeConfig.ToolCallMiddlewares = []compose.ToolMiddleware{ - {Invokable: buildDangerousToolMiddleware(dangerousNames)}, - } - logger.Infof("[Agent] 已注入危险工具审批中间件,工具列表=%v", dangerousNames) + middlewares = append(middlewares, compose.ToolMiddleware{Invokable: buildDangerousToolMiddleware(dangerousNames)}) + logger.Infof("[Agent] 已注入危险工具审批中间件: %v", dangerousNames) + } + if clarifyNames := e.clarifyToolNames(); len(clarifyNames) > 0 { + middlewares = append(middlewares, compose.ToolMiddleware{Invokable: buildClarifyMiddleware(clarifyNames)}) + logger.Infof("[Agent] 已注入澄清追问中间件: %v", clarifyNames) + } + if len(middlewares) > 0 { + toolsNodeConfig.ToolCallMiddlewares = middlewares } agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index 53fa0c4..670c0e5 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -76,11 +76,27 @@ func buildReActSystemPrompt(ctx context.Context, allTools []einoTool.BaseTool, i } if hasDangerous { sb.WriteString("7. **危险工具审批**:delete_document 等危险工具会在执行前暂停并等待用户审批,调用后流程中断,用户确认后自动继续\n") - sb.WriteString(" - ⚠️ **目标不明确先反问**:当用户说'删除那个文档'、'清理一下'、'把上面的删了'这类模糊指令,且从对话历史无法唯一确定目标时,**绝对不能编造参数调用工具**。先反问用户明确目标(例如:'你要删除的是《压力 - 07/13 16:03》那个文档吗?还是另一个?')\n") + sb.WriteString(" - ⚠️ **目标不明确先反问**:当用户说'删除那个文档'、'清理一下'、'把上面的删了'这类模糊指令,且从对话历史无法唯一确定目标时,**绝对不能编造参数调用工具**。先调用 ask_clarify 反问用户明确目标(例如:'你要删除的是《压力 - 07/13 16:03》那个文档吗?还是另一个?')\n") sb.WriteString(" - ⚠️ **禁止猜测参数**:document_id 等关键参数必须来自可靠来源(用户明确提供、get_document_info 工具查询结果、历史对话中已确认的 ID)。严禁从模糊描述或'看起来像是'的文本中猜测或编造\n") sb.WriteString(" - 调用危险工具时务必在参数里写清楚目标和原因,便于用户决策\n") } + // 澄清追问说明 + hasClarify := false + for _, entry := range internalSorted { + if entry.Name == "ask_clarify" { + hasClarify = true + break + } + } + if hasClarify { + sb.WriteString("9. **澄清追问(ask_clarify)**:当用户的指令/问题存在歧义、历史对话信息不足以唯一确定目标、或你不确定下一步该怎么做时,调用 ask_clarify 暂停执行并向用户提问\n") + sb.WriteString(" - 🎯 **触发场景**:用户说了'那个文档'、'再看一下'、'它'等指代但缺少明确上下文;或问题本身有多种理解方式;或缺少执行所需的关键信息\n") + sb.WriteString(" - 🎯 **参数格式**:question 必填(一句话,不超过 100 字);options 可选(最多 4 个选项,用户可点选也可自由输入);context 可选(为什么需要澄清)\n") + sb.WriteString(" - ⚠️ **不要滥用**:只有在无法从对话历史推断意图时才调用。明显的指令直接执行,不确定的先用检索工具找线索,真不行再澄清\n") + sb.WriteString(" - ⚠️ **澄清后恢复**:用户回答后流程自动恢复,你会收到用户的回答作为工具结果,基于回答继续完成任务\n") + } + // 外部联网工具 externals := make([]string, 0) for _, td := range allDescs { diff --git a/internal/agent/runner_adapter.go b/internal/agent/runner_adapter.go index f411b19..aff5db4 100644 --- a/internal/agent/runner_adapter.go +++ b/internal/agent/runner_adapter.go @@ -96,34 +96,60 @@ func (e *Engine) runWithRunner( } // ── Interrupt 处理 ── - if agentEvent.Action != nil && agentEvent.Action.Interrupted != nil { - if !interruptSent { - interruptSent = true - ii := agentEvent.Action.Interrupted - interruptCtx := ii.InterruptContexts - var interruptID string - var interruptInfo any - if len(interruptCtx) > 0 { - interruptID = interruptCtx[0].ID - interruptInfo = interruptCtx[0].Info - } - logger.Infof("[Agent] 执行中断,等待用户审批: checkpointID=%s, interruptID=%s, info=%v", checkpointID, interruptID, interruptInfo) - infoMap, _ := interruptInfo.(map[string]any) - eventCh <- Event{ - Type: EventInterrupt, - Title: "需要人工确认", - Detail: truncateStr(formatInterruptInfo(interruptInfo), 256), - Status: "interrupt", - Error: interruptID, - CheckpointID: checkpointID, - InterruptID: interruptID, - InterruptInfo: infoMap, - Done: true, + if agentEvent.Action != nil && agentEvent.Action.Interrupted != nil { + if !interruptSent { + interruptSent = true + ii := agentEvent.Action.Interrupted + interruptCtx := ii.InterruptContexts + var interruptID string + var infoStr string + if len(interruptCtx) > 0 { + interruptID = interruptCtx[0].ID + if s, ok := interruptCtx[0].Info.(string); ok { + infoStr = s + } + } + logger.Infof("[Agent] 执行中断: checkpointID=%s, interruptID=%s, info=%s", checkpointID, interruptID, truncateStr(infoStr, 200)) + + infoType, infoData := parseInterruptInfo(infoStr) + + if infoType == "clarify" { + eventCh <- Event{ + Type: EventInterrupt, + Title: "需要澄清", + Detail: getString(infoData, "question"), + Status: "clarify", + Error: interruptID, + CheckpointID: checkpointID, + InterruptID: interruptID, + IsClarify: true, + ClarifyQuestion: getString(infoData, "question"), + ClarifyOptions: getStringSlice(infoData, "options"), + ClarifyContext: getString(infoData, "context"), + Done: true, + } + } else { + // danger 或未知类型 → 按审批处理 + message := getString(infoData, "message") + if message == "" { + message = formatInterruptInfo(infoStr) + } + eventCh <- Event{ + Type: EventInterrupt, + Title: "需要人工确认", + Detail: truncateStr(message, 256), + Status: "interrupt", + Error: interruptID, + CheckpointID: checkpointID, + InterruptID: interruptID, + InterruptInfo: infoData, + Done: true, + } + } + return } - return + continue } - continue - } if agentEvent.Output == nil || agentEvent.Output.MessageOutput == nil { continue @@ -424,3 +450,32 @@ func buildFallbackAnswer(sources []tool.SourceDocument) string { } + +func getString(m map[string]any, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getStringSlice(m map[string]any, key string) []string { + v, ok := m[key] + if !ok { + return nil + } + switch raw := v.(type) { + case []string: + return raw + case []any: + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/internal/agent/tool_middleware.go b/internal/agent/tool_middleware.go index 7325915..ff83d6b 100644 --- a/internal/agent/tool_middleware.go +++ b/internal/agent/tool_middleware.go @@ -3,7 +3,9 @@ package agent import ( "context" "encoding/gob" + "encoding/json" "fmt" + "strings" "github.com/cloudwego/eino/compose" @@ -16,10 +18,17 @@ type DangerousToolState struct { Arguments string `json:"arguments"` } +// ClarifyState 澄清追问中间件持久化到 checkpoint 的状态 +type ClarifyState struct { + Question string `json:"question"` + Options []string `json:"options,omitempty"` + Context string `json:"context,omitempty"` // LLM 为什么要澄清 +} + func init() { - // gob 序列化 checkpoint 时需要能识别 DangerousToolState 这个 interface 实现类型 - // 只注册值类型,避免同类型值/指针重复注册导致 panic + // gob 序列化 checkpoint 时需要能识别这些 interface 实现类型 gob.Register(DangerousToolState{}) + gob.Register(ClarifyState{}) } // buildDangerousToolMiddleware 构建统一的危险工具审批中间件。 @@ -43,10 +52,11 @@ func buildDangerousToolMiddleware(dangerousNames map[string]bool) compose.Invoka wasInterrupted, hasState, state := compose.GetInterruptState[DangerousToolState](ctx) if !wasInterrupted { - // ── 首次执行:中断等待审批 ── - // info 用 string(gob 原生类型,不需要额外注册);不要用 map[string]any 这种 gob 不认识的类型 - // state 用 DangerousToolState(init 里已经 gob.Register 过) - info := fmt.Sprintf("即将执行危险工具 %s,请确认是否继续", input.Name) + info := marshalInterruptInfo("danger", map[string]any{ + "tool_name": input.Name, + "arguments": input.Arguments, + "message": fmt.Sprintf("即将执行危险工具 %s,请确认是否继续", input.Name), + }) logger.Infof("[ToolMiddleware] 危险工具 %s 触发审批中断, args=%s", input.Name, truncateStr(input.Arguments, 200)) return nil, compose.StatefulInterrupt(ctx, info, DangerousToolState{ ToolName: input.Name, @@ -90,3 +100,117 @@ func buildDangerousToolMiddleware(dangerousNames map[string]bool) compose.Invoka } } } + +// buildClarifyMiddleware 构建澄清追问中间件。 +// ask_clarify 工具被 LLM 调用时触发 Interrupt,前端显示澄清问题/选项, +// 用户回答后从 checkpoint 恢复,LLM 继续推理。 +func buildClarifyMiddleware(clarifyNames map[string]bool) compose.InvokableToolMiddleware { + return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { + return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { + if !clarifyNames[input.Name] { + return next(ctx, input) + } + + wasInterrupted, _, state := compose.GetInterruptState[ClarifyState](ctx) + + if !wasInterrupted { + q := extractClarifyQuestion(input.Arguments) + info := marshalInterruptInfo("clarify", map[string]any{ + "question": q, + "options": extractClarifyOptions(input.Arguments), + "context": extractClarifyContext(input.Arguments), + }) + logger.Infof("[ClarifyMiddleware] ask_clarify 触发中断: args=%s", truncateStr(input.Arguments, 200)) + return nil, compose.StatefulInterrupt(ctx, info, ClarifyState{ + Question: q, + Options: extractClarifyOptions(input.Arguments), + Context: extractClarifyContext(input.Arguments), + }) + } + + // 恢复执行:拿用户的回答 + isResumeFlow, hasData, answer := compose.GetResumeContext[string](ctx) + if !isResumeFlow || !hasData { + logger.Warnf("[ClarifyMiddleware] 恢复流程异常:wasInterrupted=%v, isResumeFlow=%v", wasInterrupted, isResumeFlow) + return &compose.ToolOutput{Result: "恢复流程异常:未收到用户回答"}, nil + } + + logger.Infof("[ClarifyMiddleware] 恢复执行: question=%q, answer=%q", state.Question, answer) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("✅ 用户已回答澄清问题\n\n问题:%s\n\n回答:%s", state.Question, answer)) + return &compose.ToolOutput{Result: sb.String()}, nil + } + } +} + +func extractClarifyQuestion(argsJSON string) string { + var m map[string]any + if err := json.Unmarshal([]byte(argsJSON), &m); err != nil { + return "" + } + if q, ok := m["question"].(string); ok { + return q + } + return "" +} + +func extractClarifyOptions(argsJSON string) []string { + var m map[string]any + if err := json.Unmarshal([]byte(argsJSON), &m); err != nil { + return nil + } + raw, ok := m["options"].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + +func extractClarifyContext(argsJSON string) string { + var m map[string]any + if err := json.Unmarshal([]byte(argsJSON), &m); err != nil { + return "" + } + if c, ok := m["context"].(string); ok { + return c + } + return "" +} + +type interruptInfoSchema struct { + Type string `json:"type"` + Data map[string]any `json:"data,omitempty"` +} + +func marshalInterruptInfo(typ string, data map[string]any) string { + b, err := json.Marshal(interruptInfoSchema{Type: typ, Data: data}) + if err != nil { + logger.Warnf("[Middleware] marshalInterruptInfo 失败: %v", err) + return fmt.Sprintf(`{"type":"%s"}`, typ) + } + return string(b) +} + +func parseInterruptInfo(infoStr string) (typ string, data map[string]any) { + var s interruptInfoSchema + if err := json.Unmarshal([]byte(infoStr), &s); err == nil && s.Type != "" { + return s.Type, s.Data + } + // 兼容旧格式:尝试直接当 map 解析 + var m map[string]any + if err := json.Unmarshal([]byte(infoStr), &m); err == nil { + if t, ok := m["type"].(string); ok { + if d, ok := m["data"].(map[string]any); ok { + return t, d + } + } + } + return "", nil +} diff --git a/internal/agent/types.go b/internal/agent/types.go index 967df5d..9653cd6 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -62,6 +62,11 @@ type Event struct { CheckpointID string `json:"checkpoint_id,omitempty"` InterruptID string `json:"interrupt_id,omitempty"` InterruptInfo map[string]any `json:"interrupt_info,omitempty"` + // clarify 事件字段(ask_clarify 触发的中断) + IsClarify bool `json:"is_clarify,omitempty"` + ClarifyQuestion string `json:"clarify_question,omitempty"` + ClarifyOptions []string `json:"clarify_options,omitempty"` + ClarifyContext string `json:"clarify_context,omitempty"` } // 事件类型常量 diff --git a/internal/app/app.go b/internal/app/app.go index 639a70d..c9eb512 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,4 +1,4 @@ -package app +package app import ( "context" @@ -270,6 +270,10 @@ func (a *App) initAgentComponents(toolFactory tool.ToolFactory, documentRepo rep func(ctx context.Context, userID string, kbIDs []string) einoTool.BaseTool { return tool.NewDeleteDocumentTool(documentRepo)(userID, kbIDs) }) + agentEngine.RegisterInternal("ask_clarify", 20, false, + func(ctx context.Context, userID string, kbIDs []string) einoTool.BaseTool { + return tool.NewAskClarifyTool()(userID, kbIDs) + }) return &AgentComponents{ Retriever: vectorRetriever, diff --git a/internal/model/dto/response/chat_res.go b/internal/model/dto/response/chat_res.go index ab534df..574e53f 100644 --- a/internal/model/dto/response/chat_res.go +++ b/internal/model/dto/response/chat_res.go @@ -2,12 +2,14 @@ package response import "time" -// PendingCheckpointInfo 前端恢复审批状态用 +// PendingCheckpointInfo 前端恢复审批/澄清状态用 type PendingCheckpointInfo struct { CheckpointID string `json:"checkpoint_id"` InterruptID string `json:"interrupt_id"` Question string `json:"question,omitempty"` ToolName string `json:"tool_name,omitempty"` + IsClarify bool `json:"is_clarify,omitempty"` + Options []string `json:"options,omitempty"` SetAt time.Time `json:"set_at"` } diff --git a/internal/model/entity/chat_session.go b/internal/model/entity/chat_session.go index cb58c3d..808a256 100644 --- a/internal/model/entity/chat_session.go +++ b/internal/model/entity/chat_session.go @@ -75,6 +75,8 @@ type PendingCheckpointData struct { InterruptID string `json:"interrupt_id"` Question string `json:"question,omitempty"` ToolName string `json:"tool_name,omitempty"` + IsClarify bool `json:"is_clarify,omitempty"` + Options []string `json:"options,omitempty"` SetAt time.Time `json:"set_at"` } diff --git a/internal/service/chat_service_mapper.go b/internal/service/chat_service_mapper.go index cc6dadc..1a2e5c6 100644 --- a/internal/service/chat_service_mapper.go +++ b/internal/service/chat_service_mapper.go @@ -25,6 +25,8 @@ func sessionResponse(session entity.ChatSession) response.SessionResponse { InterruptID: pc.InterruptID, Question: pc.Question, ToolName: pc.ToolName, + IsClarify: pc.IsClarify, + Options: pc.Options, SetAt: pc.SetAt, } } diff --git a/internal/service/chat_service_mode.go b/internal/service/chat_service_mode.go index 9a4274d..8233a28 100644 --- a/internal/service/chat_service_mode.go +++ b/internal/service/chat_service_mode.go @@ -123,18 +123,19 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us if session2 != nil && session2.HasPendingCheckpoint() { pc, _ := session2.GetPendingCheckpoint() if pc != nil { - logger.Infof("[ChatService] 检测到 pending checkpoint: checkpointID=%s, interruptID=%s", pc.CheckpointID, pc.InterruptID) - if req.Content != "" { - agentReq.CheckpointID = pc.CheckpointID - agentReq.ResumeData = map[string]any{ - pc.InterruptID: req.Content, + logger.Infof("[ChatService] 检测到 pending checkpoint: checkpointID=%s, interruptID=%s", pc.CheckpointID, pc.InterruptID) + if req.Content != "" { + agentReq.CheckpointID = pc.CheckpointID + agentReq.ResumeData = map[string]any{ + pc.InterruptID: req.Content, + } + logger.Infof("[ChatService] 设置恢复参数: checkpointID=%s, resumeKeys=%v", pc.CheckpointID, []string{pc.InterruptID}) + } else { + logger.Warnf("[ChatService] 有 pending checkpoint 但用户未提供审批内容,走首次执行") + _ = s.sessionRepo.ClearPendingCheckpoint(ctx, sessionID) + _ = s.sessionRepo.ClearPendingClarify(ctx, sessionID) } - logger.Infof("[ChatService] 设置恢复参数: checkpointID=%s, resumeKeys=%v", pc.CheckpointID, []string{pc.InterruptID}) - } else { - logger.Warnf("[ChatService] 有 pending checkpoint 但用户未提供审批内容,走首次执行") - _ = s.sessionRepo.ClearPendingCheckpoint(ctx, sessionID) } - } } t1 := time.Now() agentEventCh, err := s.agentEngine.Execute(deepCtx, agentReq, chatModel) @@ -169,27 +170,40 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us } // ── interrupt 事件:存 checkpoint 到 session,返回中断事件给前端 ── - if agentEvent.Type == agent.EventInterrupt { - logger.Infof("[ChatService] Agent 中断: checkpointID=%s, interruptID=%s", agentEvent.CheckpointID, agentEvent.InterruptID) - if agentEvent.CheckpointID != "" { - pcData := &entity.PendingCheckpointData{ - CheckpointID: agentEvent.CheckpointID, - InterruptID: agentEvent.InterruptID, - Question: agentEvent.Detail, - ToolName: "", - SetAt: time.Now(), - } - raw, _ := json.Marshal(pcData) - if sErr := s.sessionRepo.SetPendingCheckpoint(ctx, sessionID, raw); sErr != nil { - logger.Errorf("存储 pending checkpoint 失败: %v", sErr) - } else { - logger.Infof("[ChatService] 已存储 pending checkpoint: sessionID=%s", sessionID) + if agentEvent.Type == agent.EventInterrupt { + logger.Infof("[ChatService] Agent 中断: checkpointID=%s, interruptID=%s, isClarify=%v", agentEvent.CheckpointID, agentEvent.InterruptID, agentEvent.IsClarify) + if agentEvent.CheckpointID != "" { + pcData := &entity.PendingCheckpointData{ + CheckpointID: agentEvent.CheckpointID, + InterruptID: agentEvent.InterruptID, + Question: agentEvent.Detail, + IsClarify: agentEvent.IsClarify, + Options: agentEvent.ClarifyOptions, + SetAt: time.Now(), + } + raw, _ := json.Marshal(pcData) + if sErr := s.sessionRepo.SetPendingCheckpoint(ctx, sessionID, raw); sErr != nil { + logger.Errorf("存储 pending checkpoint 失败: %v", sErr) + } else { + logger.Infof("[ChatService] 已存储 pending checkpoint: sessionID=%s", sessionID) + } + + // clarify 中断额外存到 pending_clarify(兼容旧恢复流程) + if agentEvent.IsClarify { + clarifyData := &entity.PendingClarifyData{ + Question: agentEvent.ClarifyQuestion, + Options: agentEvent.ClarifyOptions, + SetAt: time.Now(), + } + clarifyRaw, _ := json.Marshal(clarifyData) + if sErr := s.sessionRepo.SetPendingClarify(ctx, sessionID, clarifyRaw); sErr != nil { + logger.Errorf("存储 pending clarify 失败: %v", sErr) + } + } } + eventCh <- toStreamEvent(agentEvent) + return } - eventCh <- toStreamEvent(agentEvent) - // 中断后不保存 assistant message(因为还没执行完) - return - } if agentEvent.Type == agent.EventAnswer { fullContent += agentEvent.Content @@ -217,13 +231,14 @@ func (s *chatService) processDeepMode(ctx context.Context, userID, sessionID, us applyReasoningStep(&reasoningSteps, agentEvent) } - // ── 执行成功后清除 pending checkpoint(如果有的话) ── + // ── 执行成功后清除 pending 状态 ── if agentReq.CheckpointID != "" { if cErr := s.sessionRepo.ClearPendingCheckpoint(ctx, sessionID); cErr != nil { logger.Warnf("清除 pending checkpoint 失败: %v", cErr) } else { logger.Infof("[ChatService] 恢复执行完成,已清除 pending checkpoint: sessionID=%s", sessionID) } + _ = s.sessionRepo.ClearPendingClarify(ctx, sessionID) } if obsOk { s.obs.Observe(ctx, "chat_deep_agent_seconds", map[string]string{"model_id": req.ModelID}, time.Since(t1).Seconds()) @@ -465,6 +480,12 @@ func toStreamEvent(e agent.Event) dto.StreamEvent { se.CheckpointID = e.CheckpointID se.InterruptID = e.InterruptID se.InterruptInfo = e.InterruptInfo + if e.IsClarify { + se.Clarify = &dto.ClarifyPayload{ + Question: e.ClarifyQuestion, + Options: e.ClarifyOptions, + } + } } return se } diff --git a/internal/tool/ask_clarify.go b/internal/tool/ask_clarify.go new file mode 100644 index 0000000..22afa9c --- /dev/null +++ b/internal/tool/ask_clarify.go @@ -0,0 +1,35 @@ +package tool + +import ( + "context" + "fmt" + + einoTool "github.com/cloudwego/eino/components/tool" + toolutils "github.com/cloudwego/eino/components/tool/utils" +) + +// AskClarifyInput 澄清追问工具的参数 +type AskClarifyInput struct { + Question string `json:"question" jsonschema:"required" jsonschema_description:"需要向用户澄清的问题,一句话不超过100字"` + Options []string `json:"options,omitempty" jsonschema_description:"可选选项,最多4个,用户可点选也可自由输入,开放式问题可省略"` + Context string `json:"context,omitempty" jsonschema_description:"为什么需要澄清,当前理解程度和缺少的关键信息,最多200字"` +} + +// NewAskClarifyTool 创建 ask_clarify 工具。 +// 注意:这个工具的业务逻辑是空壳——真正的拦截和 Interrupt 由 agent.buildClarifyMiddleware 处理。 +// 工具本身只返回"等待用户回答",但永远不会到达这一步(被 middleware 拦截了)。 +func NewAskClarifyTool() func(userID string, kbIDs []string) einoTool.InvokableTool { + return func(userID string, kbIDs []string) einoTool.InvokableTool { + t, _ := toolutils.InferTool[AskClarifyInput, ToolResponse]( + "ask_clarify", + "向用户澄清模糊问题。当用户的指令/问题存在歧义、历史对话信息不足以唯一确定目标、或你不确定下一步该怎么做时调用此工具,暂停并向用户提问。调用后流程中断,用户回答后自动恢复执行。不要把它当普通工具用——它会暂停整个流程等待用户回复。", + func(ctx context.Context, input AskClarifyInput) (ToolResponse, error) { + return ToolResponse{ + Success: true, + Message: fmt.Sprintf("等待用户回答澄清问题:%s", input.Question), + }, nil + }, + ) + return t + } +} diff --git a/internal/tool/delete_document.go b/internal/tool/delete_document.go index 3be5518..118ff59 100644 --- a/internal/tool/delete_document.go +++ b/internal/tool/delete_document.go @@ -1,4 +1,4 @@ -package tool +package tool import ( "context" diff --git a/internal/tool/document_tools.go b/internal/tool/document_tools.go index 6c54e23..d2fd38d 100644 --- a/internal/tool/document_tools.go +++ b/internal/tool/document_tools.go @@ -1,4 +1,4 @@ -package tool +package tool import ( "context"