From 2ecf4e903ab02a738b2dbd9ff7d62a35e84df40b Mon Sep 17 00:00:00 2001 From: whlaoding Date: Mon, 16 Mar 2026 13:17:23 +0800 Subject: [PATCH] Fix orchestrator logic and workspace push for planning confirmation and artifact handling --- internal/agent/orchestrator.go | 71 ++++++++++--------- internal/llm/client.go | 8 ++- skills/safe_pi_planning/skill.md | 2 + tools/giteaticket/giteaticket.go | 8 +++ tools/websearch/websearch.go | 113 +++++++++++++++++++++++++++++- tools/websearch/websearch_test.go | 53 ++++++++++++++ 6 files changed, 217 insertions(+), 38 deletions(-) diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go index e54f185..5b16a10 100644 --- a/internal/agent/orchestrator.go +++ b/internal/agent/orchestrator.go @@ -381,9 +381,10 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, recentMessages if planningMode { planningModeDoc = strings.Join([]string{ "当前处于 PI 规划编辑模式。", - "- 用户的修订意见必须基于现有 Artifact 继续迭代。", - "- 需要继续调用 safe_pi_planning / publish_pi_plan 相关流程生成更新版本。", - "- 不要仅给普通文本答复替代蓝图更新。", + "- 如果用户表达了确认意图(如 确认、开始创建工单、批量创建、没问题 等),你必须立即调用 create_gitea_ticket 工具,将 PI 蓝图中的 Feature 和 Enabler 逐一拆解为 User Story 并创建工单。严禁重复输出蓝图或再次征求确认。", + "- 如果用户提出了修改意见,则基于现有 Artifact 继续迭代,调用 publish_pi_plan 生成更新版本。", + "- 不要仅给普通文本答复替代蓝图更新或工单创建。必须通过工具调用来执行实际操作。", + "- 不要在文本中虚构工具调用结果。如果需要创建工单,必须实际调用 create_gitea_ticket 工具。", }, "\n") } @@ -905,38 +906,40 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID if finalText == "" { finalText = "已完成处理。" } - if planningMode && !workspaceSentThisTurn { - // 提取 artifact 标签内的文档内容作为工作区内容 + if planningMode && !workspaceSentThisTurn && !isPlanningConfirmation(userInput) { + // 仅当 LLM 输出包含 标签时,才提取其内容推送到工作区分屏 + // 如果不包含 artifact 标签,说明是 LLM 推理中间步骤,不推送到工作区 workspaceContent := extractArtifactContent(finalText) - if workspaceContent == "" { - workspaceContent = stripArtifactTags(finalText) - } - if workspaceContent == "" { - workspaceContent = finalText - } - o.activatePlanningSession(chatID, userID, workspaceContent, true) - if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil { - if o.log != nil { - o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err) + if workspaceContent != "" { + o.activatePlanningSession(chatID, userID, workspaceContent, true) + if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil { + if o.log != nil { + o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err) + } } + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceStart, + WorkspaceTitle: "PI Planning Document", + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + if err := callback(StreamEvent{ + Type: StreamEventTypeWorkspaceDelta, + Content: workspaceContent, + }); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil { + return "", fmt.Errorf("callback error: %w", err) + } + workspaceSentThisTurn = true + finalText = stripArtifactTags(finalText) + if finalText == "" { + finalText = "PI 规划已生成,请查看右侧工作区。如确认无误,请回复确认进入工单创建。" + } + } else if o.log != nil { + o.log.Infof("%s planning mode final answer has no artifact tags, skip workspace push", traceLogPrefix) } - if err := callback(StreamEvent{ - Type: StreamEventTypeWorkspaceStart, - WorkspaceTitle: "PI Planning Document", - }); err != nil { - return "", fmt.Errorf("callback error: %w", err) - } - if err := callback(StreamEvent{ - Type: StreamEventTypeWorkspaceDelta, - Content: workspaceContent, - }); err != nil { - return "", fmt.Errorf("callback error: %w", err) - } - if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil { - return "", fmt.Errorf("callback error: %w", err) - } - workspaceSentThisTurn = true - finalText = "我已根据你的意见更新 PI 规划,请查看右侧工作区;如确认无误,请回复“确认”进入工单创建。" } else if planningMode { // 工作区已在本轮通过工具调用发送,剥离 final 内容中的 artifact 标签 finalText = stripArtifactTags(finalText) @@ -1441,7 +1444,9 @@ func isPlanningConfirmation(userInput string) bool { } markers := []string{ "确认", "同意", "可以创建", "开始创建", "继续创建", "执行下一步", "没问题,继续", - "confirm", "approved", "go ahead", "proceed", + "创建工单", "批量创建", "下发工单", "同步到gitea", "同步到 gitea", + "没问题", "可以了", "没有问题", "没有异议", "无异议", + "confirm", "approved", "go ahead", "proceed", "create ticket", "create issue", } for _, marker := range markers { if strings.Contains(text, marker) { diff --git a/internal/llm/client.go b/internal/llm/client.go index 729218b..91758a8 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -152,8 +152,12 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages choice := resp.Choices[0] resultToolCalls := fromSDKToolCalls(choice.Message.ToolCalls) if c.log != nil { - c.log.Infof("llm tool-call response success model=%s content_len=%d tool_calls=%d finish=%s", - model, len(choice.Message.Content), len(resultToolCalls), choice.FinishReason) + toolNames := make([]string, 0, len(resultToolCalls)) + for _, tc := range resultToolCalls { + toolNames = append(toolNames, tc.Function.Name) + } + c.log.Infof("llm tool-call response success model=%s content_len=%d tool_calls=%d names=%v finish=%s", + model, len(choice.Message.Content), len(resultToolCalls), toolNames, choice.FinishReason) } return &ChatCompletion{ diff --git a/skills/safe_pi_planning/skill.md b/skills/safe_pi_planning/skill.md index 1839251..da61d3a 100644 --- a/skills/safe_pi_planning/skill.md +++ b/skills/safe_pi_planning/skill.md @@ -144,6 +144,8 @@ description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic 输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。 +**⚠️ 用户确认规则**:当用户回复表示确认(如"确认"、"开始创建工单"、"批量创建"、"没问题"、"可以了"等肯定性表达),**必须立即调用** `create_gitea_ticket` 工具开始创建工单,**严禁**重复输出蓝图或再次征求确认。 + --- ### 阶段 3:任务下发到 Gitea diff --git a/tools/giteaticket/giteaticket.go b/tools/giteaticket/giteaticket.go index cc88f8c..f7d8231 100644 --- a/tools/giteaticket/giteaticket.go +++ b/tools/giteaticket/giteaticket.go @@ -70,6 +70,10 @@ func New(cfg Config, log *logger.Logger) *Tool { if cfg.Timeout <= 0 { cfg.Timeout = 30 * time.Second } + if log != nil { + log.Infof("giteaticket tool initialized base_url=%q owner=%q repo=%q token_set=%v", + baseURL, strings.TrimSpace(cfg.Owner), strings.TrimSpace(cfg.Repo), strings.TrimSpace(cfg.Token) != "") + } return &Tool{ baseURL: baseURL, token: strings.TrimSpace(cfg.Token), @@ -95,6 +99,10 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) { return "", fmt.Errorf("create_gitea_ticket: validation failed: %w", err) } if t.baseURL == "" || t.token == "" || t.owner == "" || t.repo == "" { + if t.log != nil { + t.log.Errorf("giteaticket config missing: base_url=%q token_set=%v owner=%q repo=%q", + t.baseURL, t.token != "", t.owner, t.repo) + } return "", fmt.Errorf("create_gitea_ticket: missing Gitea configuration (base_url, token, owner, repo)") } diff --git a/tools/websearch/websearch.go b/tools/websearch/websearch.go index 08de622..e9d56c8 100644 --- a/tools/websearch/websearch.go +++ b/tools/websearch/websearch.go @@ -15,8 +15,8 @@ import ( // Config 定义了网络搜索工具所需的配置参数。 type Config struct { - Engine string // 搜索引擎类型,支持 "duckduckgo" 或 "brave" - APIKey string // 搜索引擎的 API Key(Brave 搜索必填) + Engine string // 搜索引擎类型,支持 "duckduckgo"、"brave" 或 "tavily" + APIKey string // 搜索引擎的 API Key(Brave 或 Tavily 搜索必填) } // Tool represents a web search tool. @@ -85,6 +85,8 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) { switch t.engine { case "brave": result, err = t.searchBrave(ctx, query) + case "tavily": + result, err = t.searchTavily(ctx, query) default: result, err = t.searchDuckDuckGo(ctx, query) } @@ -126,12 +128,20 @@ func (t *Tool) searchDuckDuckGo(ctx context.Context, query string) (string, erro return "", fmt.Errorf("read response body failed: %w", err) } + if t.log != nil { + t.log.Debugf("duckduckgo raw response: %s", string(body)) + } + var ddg duckDuckGoResponse if err := json.Unmarshal(body, &ddg); err != nil { return "", fmt.Errorf("parse duckduckgo response failed: %w", err) } - return t.formatDuckDuckGoResult(query, ddg), nil + result := t.formatDuckDuckGoResult(query, ddg) + if t.log != nil { + t.log.Infof("duckduckgo search finished, content_found=%v", (ddg.Answer != "" || ddg.AbstractText != "" || len(ddg.RelatedTopics) > 0)) + } + return result, nil } // duckDuckGoResponse 从 DuckDuckGo 获取的即时结果 JSON 映射结构。 @@ -239,11 +249,19 @@ func (t *Tool) searchBrave(ctx context.Context, query string) (string, error) { return "", fmt.Errorf("read response body failed: %w", err) } + if t.log != nil { + t.log.Debugf("brave search raw response: %s", string(body)) + } + var braveResp braveSearchResponse if err := json.Unmarshal(body, &braveResp); err != nil { return "", fmt.Errorf("parse brave response failed: %w", err) } + if t.log != nil { + t.log.Infof("brave search finished, results_count=%d", len(braveResp.Web.Results)) + } + return t.formatBraveResult(query, braveResp), nil } @@ -286,3 +304,92 @@ func (t *Tool) formatBraveResult(query string, resp braveSearchResponse) string return strings.TrimSpace(b.String()) } + +// searchTavily uses the Tavily Search API (requires API key). +func (t *Tool) searchTavily(ctx context.Context, query string) (string, error) { + if t.apiKey == "" { + return "", fmt.Errorf("WEB_SEARCH_API_KEY is required for Tavily engine") + } + + apiURL := "https://api.tavily.com/search" + payload := map[string]interface{}{ + "api_key": t.apiKey, + "query": query, + "search_depth": "basic", + "include_answer": true, + "include_images": false, + "include_raw_content": false, + "max_results": 5, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("marshal tavily payload failed: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(jsonData))) + if err != nil { + return "", fmt.Errorf("create request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := t.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("http request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("tavily search returned status %d: %s", resp.StatusCode, string(bodySnippet)) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) + if err != nil { + return "", fmt.Errorf("read response body failed: %w", err) + } + + if t.log != nil { + t.log.Debugf("tavily search raw response: %s", string(body)) + } + + var tavilyResp tavilyResponse + if err := json.Unmarshal(body, &tavilyResp); err != nil { + return "", fmt.Errorf("parse tavily response failed: %w", err) + } + + return t.formatTavilyResult(query, tavilyResp), nil +} + +type tavilyResponse struct { + Answer string `json:"answer"` + Results []tavilyResult `json:"results"` +} + +type tavilyResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Score float64 `json:"score"` +} + +func (t *Tool) formatTavilyResult(query string, resp tavilyResponse) string { + b := strings.Builder{} + b.WriteString("Search: " + query + "\n") + b.WriteString("Engine: Tavily\n\n") + + if resp.Answer != "" { + b.WriteString("Answer: " + resp.Answer + "\n\n") + } + + if len(resp.Results) == 0 && resp.Answer == "" { + b.WriteString("No results found.\n") + return strings.TrimSpace(b.String()) + } + + for i, r := range resp.Results { + b.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Content)) + } + + return strings.TrimSpace(b.String()) +} diff --git a/tools/websearch/websearch_test.go b/tools/websearch/websearch_test.go index 22cfa8a..c6d1ab2 100644 --- a/tools/websearch/websearch_test.go +++ b/tools/websearch/websearch_test.go @@ -1,9 +1,62 @@ package websearch import ( + "context" + "fmt" "testing" ) +func TestTavilyIntegration(t *testing.T) { + // 这是一个集成测试,会实际请求网络。 + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // 这里的 API Key 从环境变量中获取或手动填写的测试环境变量中读取。 + apiKey := "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" + if apiKey == "" { + t.Skip("TAVILY_API_KEY is not set") + } + + tool := New(Config{Engine: "tavily", APIKey: apiKey}, 4000, nil) + ctx := context.Background() + query := "wuhan weather today" + + result, err := tool.Call(ctx, query) + if err != nil { + t.Fatalf("Tavily search failed: %v", err) + } + + fmt.Printf("Tavily search result for '%s':\n%s\n", query, result) + + if result == "" { + t.Fatal("expected non-empty result from Tavily") + } +} + +func TestDuckDuckGoIntegration(t *testing.T) { + // 这是一个集成测试,会实际请求网络。 + // 在 CI 环境中可能需要跳过。 + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + tool := New(Config{Engine: "duckduckgo"}, 4000, nil) + ctx := context.Background() + query := "wuhan weather" + + result, err := tool.Call(ctx, query) + if err != nil { + t.Fatalf("DuckDuckGo search failed: %v", err) + } + + fmt.Printf("DuckDuckGo search result for '%s':\n%s\n", query, result) + + if result == "" { + t.Fatal("expected non-empty result from DuckDuckGo") + } +} + func TestNewDefaultEngine(t *testing.T) { tool := New(Config{}, 4000, nil) if tool.Name() != "web_search" {