Files
Agent-System/internal/coordinator/engine_task.go
T
weichen f8730791b9 fix(coordinator): 回填会话项目绑定,避免受控投影被跳过
聊天入口只把项目 ID 放在授权字段,协调器仍读空的 UserCtx.Project.ID,受控任务因此以 project_id=0 跳过账本投影,回复却称正文已保存。
2026-09-28 12:34:32 +00:00

359 lines
13 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 coordinator
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/contracts/vocabulary"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowscope"
"chat-agent-system/internal/workspace"
)
func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
// 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。
if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
active := ""
if userCtx != nil {
active = userCtx.Project.ActiveWorkflowKey
}
guidance := cand.ClarificationQuestion
if guidance == "" {
workflows := []string{}
if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" {
workflows = strings.Split(v, ",")
}
guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active)
}
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: guidance,
NaturalReply: guidance,
UpdatedContext: userCtx,
}, nil
}
taskKey := cand.FilledSlots[intent.SlotTargetTask]
if taskKey == "" {
taskKey = "intake"
}
// 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。
if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" {
owner := intent.TaskKeyWorkflowOwner(taskKey)
if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) {
guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey)
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: cand.Intent,
Branch: cand.Branch,
ClarificationPrompt: guidance,
NaturalReply: guidance,
UpdatedContext: userCtx,
}, nil
}
}
// 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。
if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil {
return gated, nil
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
if e.tasks() == nil {
return &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已收到生成【%s】的指令。", taskKey),
UpdatedContext: userCtx,
NextExpectation: nextExp,
}, nil
}
var projectID, userID int64
if userCtx.Project.ID != "" {
projectID = parseDecimalID(userCtx.Project.ID)
}
if userCtx.User.ID != "" {
userID = parseDecimalID(userCtx.User.ID)
}
taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{
TaskKey: taskKey,
ProjectName: userCtx.Project.Name,
CustomerName: userCtx.Project.CustomerName,
DeviceType: userCtx.Project.DeviceType,
IntendedUse: userCtx.Project.IntendedUse,
TargetMarket: userCtx.Project.TargetMarket,
Fields: userCtx.BlackboardFacts,
SessionID: userCtx.ChatSessionID,
ProjectID: projectID,
UserID: userID,
// 会话触发与特设任务一致:质量门禁失败时给修复预算。
// 受控文档仍由 bizdev 强制开启;预算硬停在 DefaultMaxRepairRounds。
AutoRepair: true,
})
if err != nil {
if errors.Is(err, vocabulary.ErrDocumentBusy) {
msg := "该文档正在由项目助手生成或他人编辑中,请稍后再发起更新。您可以先继续对话或处理其他文档。"
if taskRes != nil && taskRes.ErrorReason != "" {
msg = taskRes.ErrorReason + "。您可以先继续对话或处理其他文档。"
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: msg,
UpdatedContext: userCtx,
}, nil
}
if errors.Is(err, vocabulary.ErrDocumentLocked) {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: "该文档已进入审核/批准锁定状态,无法自动覆写正文。如需修订请走驳回解锁或变更提案流程。",
UpdatedContext: userCtx,
}, nil
}
if taskRes != nil && strings.Contains(taskRes.ErrorReason, "会话未绑定项目") {
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: taskRes.ErrorReason,
NaturalReply: taskRes.ErrorReason,
UpdatedContext: userCtx,
}, nil
}
if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) {
msg := userFacingPrerequisite(err)
if taskRes != nil && strings.Contains(taskRes.ErrorReason, "请先完成商务对接流程") {
msg = taskRes.ErrorReason[strings.Index(taskRes.ErrorReason, "请先完成商务对接流程"):]
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: msg,
UpdatedContext: userCtx,
}, nil
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: fmt.Sprintf("任务执行失败: %v", err),
UpdatedContext: userCtx,
}, err
}
userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief
outPath := filepath.Join(taskRes.WorkDir, workspace.DeliverableKey)
reply, taskStatus := formatTriggerTaskReply(taskRes, outPath)
res := &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: reply,
AdvisorBrief: taskRes.AdvisorBrief,
ArtifactPath: outPath,
UpdatedContext: userCtx,
NextExpectation: nextExp,
TaskStatus: taskStatus,
TaskKey: taskRes.TaskKey,
ArtifactTitle: taskRes.ArtifactTitle,
}
attachTaskRunProjection(res, taskRes)
return e.applyNarrative(ctx, userCtx, "handleTriggerTask", "", "", res), nil
}
// formatTriggerTaskReply builds sync coordinator copy for a TaskRunResult.
// Pending high-risk candidates must not claim "正文已保存 / 已完成".
func userFacingPrerequisite(err error) string {
const hint = "请先完成商务对接流程"
if err != nil && strings.Contains(err.Error(), hint) {
return err.Error()[strings.Index(err.Error(), hint):]
}
return "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程"
}
func formatTriggerTaskReply(taskRes *stageflow.TaskRunResult, outPath string) (reply string, taskStatus string) {
_ = outPath // 不把宿主机工作区路径写进用户可见回复
if taskRes == nil {
return "任务执行失败。", stageflow.TaskStatusFailed
}
taskStatus = taskRes.TerminalStatus()
if taskRes.AwaitsAgentConfirmation() || taskStatus != stageflow.TaskStatusCompleted {
return taskRes.CompletionSummary(), taskStatus
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已为您自适应规划并完成生成【%s】!\n", taskRes.ArtifactTitle))
if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" {
sb.WriteString(fmt.Sprintf("交付物已写入受控文档:%s\n", taskRes.DocRecord.DocID))
sb.WriteString("正文已保存。\n")
} else {
sb.WriteString("交付物已生成于工作区,尚未写入受控文档账本。\n")
}
sb.WriteString(fmt.Sprintf("耗时: %v\n", taskRes.Duration))
if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 {
sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n")
for _, a := range taskRes.AdvisorBrief.BaselineAssumptions {
sb.WriteString(fmt.Sprintf(" • %s\n", a))
}
}
return sb.String(), taskStatus
}
func attachTaskRunProjection(res *CoordinationResult, taskRes *stageflow.TaskRunResult) {
if res == nil || taskRes == nil {
return
}
if taskRes.DocRecord != nil {
res.DocID = taskRes.DocRecord.DocID
}
if taskRes.AwaitsAgentConfirmation() {
res.PendingAgentCandidateID = taskRes.PendingAgentCandidateID
res.PendingChangeReportID = taskRes.PendingChangeReportID
res.PendingRiskLevel = taskRes.PendingRiskLevel
}
}
// handleTriggerAdhocTask 处理自由任务升级确认(路由层 R1 升级建议的兑现路径):
// 基于任务简报组装特设小队,走 planAutonomousAdhoc 自主规划 + TaskGov 全量治理
// (预算账本 / 执行轨迹 / 重复识别 / 归档锁定 / 质量门禁修复闭环)。
func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext, rawInput string) (*CoordinationResult, error) {
brief := cand.FilledSlots[intent.SlotTaskBrief]
if brief == "" {
brief = rawInput
}
title := deriveAdhocTitle(brief)
taskKey := sanitizeAdhocTaskKey(title)
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
if e.tasks() == nil {
return e.applyNarrative(ctx, userCtx, "handleTriggerAdhocTask", rawInput, "", &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: fmt.Sprintf("已收到自由任务《%s》的升级指令(当前环境未接入小队服务,简报已登记)。\n任务简报: %s", title, brief),
UpdatedContext: userCtx,
NextExpectation: nextExp,
}), nil
}
var projectID, userID int64
if userCtx.Project.ID != "" {
projectID = parseDecimalID(userCtx.Project.ID)
}
if userCtx.User.ID != "" {
userID = parseDecimalID(userCtx.User.ID)
}
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("squad_assembly", fmt.Sprintf("正在为自由任务《%s》组建特设智能体小队(动态规划 + 预算管控 + 归档锁定)...", title), "leader", 0, 0, 5)
}
// 任务简报材料化:作为首要输入材料注入工作区(context/materials_text/task_brief.md),
// 供 organizer 专员在容器内直接读到用户原始诉求(否则首步将报"无材料可整理")
briefMaterial := stageflow.MaterialInput{
Filename: "task_brief.md",
Content: []byte(fmt.Sprintf("# 任务简报(用户原始诉求,权威等级:primary_evidence)\n\n%s\n", brief)),
}
taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{
TaskKey: taskKey,
Title: title,
ProjectName: userCtx.Project.Name,
CustomerName: userCtx.Project.CustomerName,
DeviceType: userCtx.Project.DeviceType,
IntendedUse: userCtx.Project.IntendedUse,
TargetMarket: userCtx.Project.TargetMarket,
Fields: userCtx.BlackboardFacts,
Materials: []stageflow.MaterialInput{briefMaterial},
AutoRepair: true,
SessionID: userCtx.ChatSessionID,
ProjectID: projectID,
UserID: userID,
})
if err != nil {
if taskRes != nil && taskRes.WorkDir != "" {
userCtx.WorkDir = taskRes.WorkDir
}
return &CoordinationResult{
Status: StatusRejected,
Intent: cand.Intent,
Branch: cand.Branch,
RejectReason: err.Error(),
NaturalReply: fmt.Sprintf("自由任务《%s》执行失败: %v", title, err),
UpdatedContext: userCtx,
}, err
}
userCtx.LatestAdvisorBrief = taskRes.AdvisorBrief
if taskRes.WorkDir != "" {
userCtx.WorkDir = taskRes.WorkDir
}
outPath := filepath.Join(taskRes.WorkDir, workspace.DeliverableKey)
var sb strings.Builder
taskStatus := taskRes.TerminalStatus()
if taskRes.AwaitsAgentConfirmation() {
sb.WriteString(taskRes.CompletionSummary())
} else {
sb.WriteString(fmt.Sprintf("已为您组建特设小队并完成自由任务《%s》!\n任务简报: %s\n交付物正文已保存至:%s\n耗时: %v\n",
title, brief, outPath, taskRes.Duration))
if taskRes.QualityReport != nil {
verdict := "⚠️ 未完全通过"
if taskRes.QualityReport.Passed {
verdict = "✅ 通过"
}
sb.WriteString(fmt.Sprintf("质量门禁: %s(评分 %.0f)\n", verdict, taskRes.QualityReport.Score))
}
if gov := governanceSummary(taskRes.WorkDir); gov != "" {
sb.WriteString("治理审计: " + gov + "\n")
}
if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 {
sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n")
for _, a := range taskRes.AdvisorBrief.BaselineAssumptions {
sb.WriteString(fmt.Sprintf(" • %s\n", a))
}
}
}
res := &CoordinationResult{
Status: StatusAccepted,
Intent: cand.Intent,
Branch: cand.Branch,
NaturalReply: sb.String(),
AdvisorBrief: taskRes.AdvisorBrief,
ArtifactPath: outPath,
UpdatedContext: userCtx,
NextExpectation: nextExp,
TaskStatus: taskStatus,
TaskKey: taskRes.TaskKey,
ArtifactTitle: taskRes.ArtifactTitle,
}
attachTaskRunProjection(res, taskRes)
return e.applyNarrative(ctx, userCtx, "handleTriggerAdhocTask", rawInput, "", res), nil
}