feat(coordinator+server): narrow document match pool to active workflow

- ActiveWorkflowScope resolves live instance task keys for the project
- chat document reader matches inside the active pool; cross-flow hits
  return ErrWrongWorkflow with switch guidance
- enrichSessionWorkflowStage injects ActiveWorkflowKey into UserCtx
- TRIGGER_TASK (coordinator + task-run API) blocks out-of-scope task keys

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 08:05:44 +00:00
co-authored by Cursor
parent 5c6b4f7719
commit 0912efdbd9
10 changed files with 403 additions and 9 deletions
+19
View File
@@ -3,6 +3,7 @@ package coordinator
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -75,6 +76,24 @@ type ReadableDocument struct {
// 协调层不得直接枚举文档,以免绕过阶段可见性规则。
type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDocument, error)
// ErrWrongWorkflow 表示目标文档存在但不属于当前激活工作流。
// 服务端文档读取器按激活流收窄匹配池后,用该错误携带「请先切换工作流」引导话术,
// 协调层识别后原样回复,不执行任何读/写动作。
type ErrWrongWorkflow struct {
Message string
}
func (e *ErrWrongWorkflow) Error() string { return e.Message }
// WrongWorkflowGuidance 若错误链上是跨工作流拦截,返回引导话术;否则空串。
func WrongWorkflowGuidance(err error) string {
var target *ErrWrongWorkflow
if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" {
return target.Message
}
return ""
}
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
type UserProjectContext struct {
mu sync.Mutex
+3
View File
@@ -28,6 +28,9 @@ func (e *CoordinatorEngine) handleDocumentSummary(
doc, err := userCtx.DocumentSummaryReader(ctx, target)
if err != nil || doc == nil {
if guidance := WrongWorkflowGuidance(err); guidance != "" {
return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil
}
return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil
}
if doc.IsTemplateOnly {
+39
View File
@@ -928,11 +928,50 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
}
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 != "" {
if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey {
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
}
}
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
userCtx.applyPending(nextExp)
+15
View File
@@ -306,6 +306,21 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn
}
}
// WorkflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。
func WorkflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string {
return workflowScopeGuidanceQuestion(doc, workflows, activeWorkflow)
}
// WorkflowDisplayName 返回工作流键的中文展示名。
func WorkflowDisplayName(key string) string {
return workflowDisplayName(key)
}
// TaskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。
func TaskKeyWorkflowOwner(taskKey string) string {
return taskKeyWorkflowOwner(taskKey)
}
// workflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。
func workflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string {
names := make([]string, 0, len(workflows))
@@ -0,0 +1,71 @@
package projectinstance
import (
"context"
"chat-agent-system/internal/workflowdef"
)
// ActiveWorkflowScope 返回项目当前激活工作流的键与文档任务键集合。
//
// 语义(供意图填槽与文档正文读取收窄作用域):
// - 从未激活(无 live 实例)→ workflowKey 为空、taskKeys 为 nil → 调用方不得过滤(legacy / planning)
// - live 系实例 → workflowKey 取自 projects.active_workflow_key(回落实例快照定义键),
// taskKeys 收集全部启用文档任务,并补齐旧键/新键双向别名
// - completed / terminated 不属于激活:active_workflow_key 已在终止时清空,视为未激活
// - 存储错误原样上抛(fail-closed)
func (s *Service) ActiveWorkflowScope(ctx context.Context, projectID int64) (workflowKey string, taskKeys map[string]bool, err error) {
if s == nil || s.store == nil || projectID <= 0 {
return "", nil, nil
}
_, activeKey, _, _, err := s.store.GetProjectMeta(ctx, projectID)
if err != nil {
return "", nil, err
}
if activeKey == "" {
// 未激活:不过滤,保持现状行为。
return "", nil, nil
}
live, err := s.store.GetLiveInstance(ctx, projectID)
if err != nil {
return "", nil, err
}
if live == nil {
// 有激活键但无 live 快照:仅返回键供话术,任务集合交由调用方按空处理(不过滤)。
return activeKey, nil, nil
}
if live.ConfigSnapshot.WorkflowDefinitionKey != "" {
workflowKey = live.ConfigSnapshot.WorkflowDefinitionKey
} else {
workflowKey = activeKey
}
taskKeys = make(map[string]bool)
for _, st := range live.ConfigSnapshot.Stages {
if !st.Enabled {
continue
}
for _, dt := range st.DocTasks {
if !dt.Enabled || dt.TaskKey == "" {
continue
}
taskKeys[dt.TaskKey] = true
// 补齐旧键/新键双向别名,冻结实例与发布清单互指。
if canonical := workflowdef.CanonicalTaskKey(dt.TaskKey); canonical != dt.TaskKey {
taskKeys[canonical] = true
}
if legacy := workflowdef.ImplementationTaskKey(dt.TaskKey); legacy != dt.TaskKey {
taskKeys[legacy] = true
}
}
}
return workflowKey, taskKeys, nil
}
// TaskKeyInScope 判断任务键是否落在激活工作流任务集合内。
// taskKeys 为空(未激活 / 无快照)时一律放行,保持现状。
func TaskKeyInScope(taskKeys map[string]bool, taskKey string) bool {
if len(taskKeys) == 0 {
return true
}
return mapHasTaskKey(taskKeys, taskKey)
}
@@ -0,0 +1,45 @@
package projectinstance_test
import (
"context"
"testing"
"chat-agent-system/internal/projectinstance"
)
func TestActiveWorkflowScope_NoLiveInstanceReturnsEmpty(t *testing.T) {
store := projectinstance.NewMemoryStore()
if err := projectinstance.SeedProjectMeta(store, 99, projectinstance.LifecyclePlanning, "", 1, 0); err != nil {
t.Fatalf("seed: %v", err)
}
svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil)
key, tasks, err := svc.ActiveWorkflowScope(context.Background(), 99)
if err != nil {
t.Fatalf("scope: %v", err)
}
if key != "" || tasks != nil {
t.Fatalf("unactivated project must return empty scope, got key=%q tasks=%v", key, tasks)
}
}
func TestTaskKeyInScope_EmptyAllowsAll(t *testing.T) {
if !projectinstance.TaskKeyInScope(nil, "anything") {
t.Fatal("nil scope must allow")
}
if !projectinstance.TaskKeyInScope(map[string]bool{}, "anything") {
t.Fatal("empty scope must allow")
}
}
func TestTaskKeyInScope_CanonicalAlias(t *testing.T) {
scope := map[string]bool{"bd_research": true, "research": true}
if !projectinstance.TaskKeyInScope(scope, "research") {
t.Fatal("legacy alias research should be in scope")
}
if !projectinstance.TaskKeyInScope(scope, "bd_research") {
t.Fatal("canonical bd_research should be in scope")
}
if projectinstance.TaskKeyInScope(scope, "rd_proposal") {
t.Fatal("rd_proposal must be out of scope")
}
}
+146 -7
View File
@@ -10,6 +10,8 @@ import (
"chat-agent-system/internal/chat"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/projectinstance"
)
// bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。
@@ -40,7 +42,13 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
return nil, err
}
docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs)
doc, err := selectReadableWorkflowDocument(docs, target)
// 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。
// 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" {
workflowKey = sess.UserCtx.Project.ActiveWorkflowKey
}
doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys)
if err != nil {
return nil, err
}
@@ -60,21 +68,152 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth
}
}
func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, target string) (*docgov.WorkflowDocumentSummary, error) {
// activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。
func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) {
if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 {
return "", nil
}
workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID)
if err != nil {
return "", nil
}
return workflowKey, taskKeys
}
// taskKeyOutsideActiveWorkflow 判断任务键是否落在激活工作流之外。
// 未激活 / 任务键未知归属时放行(存量兼容);跨流时返回引导话术。
func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int64, taskKey string) (bool, string) {
taskKey = strings.TrimSpace(taskKey)
if taskKey == "" || projectID <= 0 {
return false, ""
}
workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID)
if workflowKey == "" {
return false, ""
}
if len(scopeTaskKeys) > 0 {
if projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) {
return false, ""
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if owner == "" || owner == workflowKey {
return false, ""
}
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
owner := intent.TaskKeyWorkflowOwner(taskKey)
if owner == "" || owner == workflowKey {
return false, ""
}
return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey)
}
// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。
type errDocWrongWorkflow struct {
Title string
TaskKey string
OwnerWorkflow string
ActiveWorkflow string
}
func (e *errDocWrongWorkflow) Error() string {
if e == nil {
return "document belongs to another workflow"
}
owners := []string{}
if e.OwnerWorkflow != "" {
owners = []string{e.OwnerWorkflow}
}
doc := e.Title
if doc == "" {
doc = e.TaskKey
}
return intent.WorkflowScopeGuidanceQuestion(doc, owners, e.ActiveWorkflow)
}
func (e *errDocWrongWorkflow) AsCoordinatorErr() error {
return &coordinator.ErrWrongWorkflow{Message: e.Error()}
}
// selectReadableWorkflowDocument 在可读文档池中解析目标。
// scopeTaskKeys 非空时先在激活流池内匹配;未命中但精确/归一化匹配到被剔除的他流文档时返回 ErrWrongWorkflow。
// scopeTaskKeys 为空(未激活 / planning)时保持现状:在全量池内匹配。
func selectReadableWorkflowDocument(
docs []docgov.WorkflowDocumentSummary,
target string,
activeWorkflow string,
scopeTaskKeys map[string]bool,
) (*docgov.WorkflowDocumentSummary, error) {
if len(docs) == 0 {
return nil, fmt.Errorf("no readable workflow documents")
}
targetKey := normalizeDocumentLookupKey(target)
if targetKey == "" {
if len(docs) == 1 {
return &docs[0], nil
inScope := filterDocsByTaskKeys(docs, scopeTaskKeys)
pool := inScope
if len(scopeTaskKeys) == 0 {
pool = docs
}
if len(pool) == 1 {
return &pool[0], nil
}
return nil, fmt.Errorf("document target required")
}
inScope := filterDocsByTaskKeys(docs, scopeTaskKeys)
pool := inScope
if len(scopeTaskKeys) == 0 {
pool = docs
}
if hit := matchDocumentInPool(pool, target, targetKey); hit != nil {
return hit, nil
}
// 激活流池未命中:检查是否精确/归一化命中被剔除的他流文档。
if len(scopeTaskKeys) > 0 {
outOfScope := filterDocsOutsideTaskKeys(docs, scopeTaskKeys)
if hit := matchDocumentInPool(outOfScope, target, targetKey); hit != nil {
owner := intent.TaskKeyWorkflowOwner(hit.TaskKey)
return nil, (&errDocWrongWorkflow{
Title: hit.Title,
TaskKey: hit.TaskKey,
OwnerWorkflow: owner,
ActiveWorkflow: activeWorkflow,
}).AsCoordinatorErr()
}
}
return nil, fmt.Errorf("document not found in readable scope")
}
func filterDocsByTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary {
if len(taskKeys) == 0 {
return docs
}
out := make([]docgov.WorkflowDocumentSummary, 0, len(docs))
for _, d := range docs {
if projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) {
out = append(out, d)
}
}
return out
}
func filterDocsOutsideTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary {
if len(taskKeys) == 0 {
return nil
}
out := make([]docgov.WorkflowDocumentSummary, 0)
for _, d := range docs {
if !projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) {
out = append(out, d)
}
}
return out
}
func matchDocumentInPool(docs []docgov.WorkflowDocumentSummary, target, targetKey string) *docgov.WorkflowDocumentSummary {
for i := range docs {
if target == docs[i].DocID || target == docs[i].TaskKey || target == docs[i].Title {
return &docs[i], nil
return &docs[i]
}
}
for i := range docs {
@@ -85,11 +224,11 @@ func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, targe
}
for _, key := range keys {
if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) {
return &docs[i], nil
return &docs[i]
}
}
}
return nil, fmt.Errorf("document not found in readable scope")
return nil
}
func normalizeDocumentLookupKey(value string) string {
+46 -2
View File
@@ -1,8 +1,11 @@
package server
import (
"errors"
"strings"
"testing"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
)
@@ -11,7 +14,7 @@ func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testin
{DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"},
{DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"},
}
doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表")
doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil)
if err != nil {
t.Fatalf("select: %v", err)
}
@@ -25,7 +28,48 @@ func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing.
{DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"},
{DocID: "doc_b", TaskKey: "research", Title: "调研报告"},
}
if _, err := selectReadableWorkflowDocument(docs, ""); err == nil {
if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil {
t.Fatal("expected target-required error")
}
}
func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"},
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
scope := map[string]bool{"bd_research": true, "research": true}
doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope)
if err != nil {
t.Fatalf("in-scope select: %v", err)
}
if doc.TaskKey != "bd_research" {
t.Fatalf("want bd_research, got %s", doc.TaskKey)
}
_, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope)
if err == nil {
t.Fatal("expected wrong-workflow error")
}
var wrong *coordinator.ErrWrongWorkflow
if !errors.As(err, &wrong) {
t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err)
}
if !strings.Contains(wrong.Message, "切换") {
t.Fatalf("guidance should mention 切换, got %q", wrong.Message)
}
}
func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) {
docs := []docgov.WorkflowDocumentSummary{
{DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"},
}
doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil)
if err != nil {
t.Fatalf("legacy passthrough: %v", err)
}
if doc.TaskKey != "rd_proposal" {
t.Fatalf("want rd_proposal, got %s", doc.TaskKey)
}
}
+8
View File
@@ -290,6 +290,14 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
return
}
}
// 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。
if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked {
writeJSONResponse(w, http.StatusConflict, map[string]string{
"error_code": "ERR_DOC_WRONG_WORKFLOW",
"message": guidance,
})
return
}
}
// 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例
+11
View File
@@ -145,6 +145,17 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess
if err == nil && settings != nil {
sess.ActiveStageKey = settings.ActiveStageKey
}
// 工作流作用域:把项目激活工作流注入会话上下文,供 L1 填槽与消费端收窄。
// 查询失败按空处理(= 不过滤),不得沿用上一轮的过期键。
if sess.UserCtx != nil {
if err == nil && settings != nil {
sess.UserCtx.Project.ActiveWorkflowKey = settings.ActiveWorkflowKey
} else {
sess.UserCtx.Project.ActiveWorkflowKey = ""
}
}
} else if sess.UserCtx != nil {
sess.UserCtx.Project.ActiveWorkflowKey = ""
}
scope, err := s.resolveChatMessageScope(ctx, sess.Project(), sess.ActiveStageKey)
if err == nil && scope != nil {