feat: add team work classification routing (#1379)

Approved by github-maintain bot. Clean team work classification feature with comprehensive tests and i18n.
This commit is contained in:
nguyenha935 authored and GitHub committed 2026-07-07 01:21:12 +07:00
1 parent f31214f5ce
commit f826738ee6
34 files changed
+1715 -57

No files matched your search

+546
View File
@@ -0,0 +1,546 @@
package teamworkclassify
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
type Decision string
const (
DecisionSelf Decision = "self"
DecisionTeam Decision = "team"
)
type Mode string
const (
ModeSpawn Mode = "spawn"
ModeDelegate Mode = "delegate"
ModeTeam Mode = "team"
)
const (
DefaultCloseMargin = 0.08
defaultTeamThreshold = 0.35
defaultEvidenceTimeout = 8 * time.Second
defaultArbiterTimeout = 30 * time.Second
)
type Embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
}
type Profile struct {
Kind string
Name string
Text string
}
type Input struct {
Mode Mode
Message string
CurrentAgent Profile
SelfTools []Profile
Team Profile
Members []Profile
Delegates []Profile
CollaborationTools []Profile
TeamRole string
CanAssignTeamTasks bool
MemberRequestsEnabled bool
MemberRequestsAutoDispatch bool
Embedder Embedder
CloseMargin float64
TeamThreshold float64
Timeout time.Duration
}
type Result struct {
Decision Decision
Confidence float64
Reason string
SelfScore float64
CollaborationScore float64
Mode Mode
RequiredTool string
WorkflowHint string
}
func Classify(ctx context.Context, input Input) Result {
if input.Mode == "" || input.Mode == ModeSpawn {
return Result{Decision: DecisionSelf, Reason: "no team or delegate capability"}
}
if input.Embedder == nil {
return Result{Decision: DecisionSelf, Reason: "embedding unavailable"}
}
if strings.TrimSpace(input.Message) == "" {
return Result{Decision: DecisionSelf, Reason: "empty message"}
}
if looksCasualOrSmallDirect(input.Message) {
return Result{Decision: DecisionSelf, Reason: "message looks casual or direct"}
}
timeout := input.Timeout
if timeout <= 0 {
timeout = defaultEvidenceTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
selfDocs, collaborationDocs := splitProfileDocuments(input)
if len(selfDocs) == 0 || len(collaborationDocs) == 0 {
return Result{Decision: DecisionSelf, Reason: "insufficient collaboration profile"}
}
texts := append([]string{input.Message}, append(selfDocs, collaborationDocs...)...)
vectors, err := input.Embedder.Embed(ctx, texts)
if err != nil || len(vectors) != len(texts) {
return Result{Decision: DecisionSelf, Reason: "embedding failed"}
}
query := vectors[0]
selfScore := bestCosine(query, vectors[1:1+len(selfDocs)])
collabScore := bestCosine(query, vectors[1+len(selfDocs):])
margin := input.CloseMargin
if margin <= 0 {
margin = DefaultCloseMargin
}
threshold := input.TeamThreshold
if threshold <= 0 {
threshold = defaultTeamThreshold
}
diff := collabScore - selfScore
bestScore := math.Max(selfScore, collabScore)
switch {
case math.Abs(diff) <= margin:
return Result{
Decision: DecisionSelf,
Confidence: bestScore,
Reason: "profiles are close; defaulting to self",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
}
case diff > margin && collabScore >= threshold:
return Result{
Decision: DecisionTeam,
Confidence: diff,
Reason: "request is closer to team/delegate capability",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
RequiredTool: requiredToolForMode(input.Mode),
}
default:
return Result{
Decision: DecisionSelf,
Confidence: -diff,
Reason: "request is closer to current agent capability",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
}
}
}
func ClassifyWithLLM(ctx context.Context, input Input, provider providers.Provider, model string, caps *usagecaps.Service) Result {
fallback := Classify(ctx, input)
if input.Mode == "" || input.Mode == ModeSpawn || provider == nil || strings.TrimSpace(model) == "" {
return forceSelfDecision(fallback, "arbiter unavailable: ")
}
timeout := input.Timeout
if timeout <= 0 {
timeout = defaultArbiterTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req := providers.ChatRequest{
Messages: BuildArbiterMessages(input, fallback),
Model: model,
Options: map[string]any{
providers.OptMaxTokens: 300,
providers.OptTemperature: 0.0,
},
}
var (
resp *providers.ChatResponse
err error
)
if caps != nil {
resp, err = caps.Chat(ctx, provider, req, usagecaps.ChatOptions{
ModelID: model,
Purpose: "team-work-classify",
MaxOutputTokens: 300,
})
} else {
resp, err = provider.Chat(ctx, req)
}
if err != nil || resp == nil {
slog.Warn("team_work_classify: arbiter call failed",
"model", model,
"provider_type", fmt.Sprintf("%T", provider),
"has_usage_caps", caps != nil,
"mode", input.Mode,
"timeout", timeout.String(),
"response_nil", resp == nil,
"error", err,
)
return forceSelfDecision(fallback, "arbiter_failed: ")
}
result, err := ParseArbiterResult(resp.Content, input.Mode)
if err != nil {
slog.Warn("team_work_classify: arbiter parse failed",
"model", model,
"provider_type", fmt.Sprintf("%T", provider),
"mode", input.Mode,
"content_len", len(resp.Content),
"error", err,
)
return forceSelfDecision(fallback, "arbiter_parse_failed: ")
}
result.SelfScore = fallback.SelfScore
result.CollaborationScore = fallback.CollaborationScore
if result.Reason == "" {
result.Reason = fallback.Reason
}
return applyTeamPermissionGate(input, result)
}
func forceSelfDecision(evidence Result, reasonPrefix string) Result {
evidence.Decision = DecisionSelf
evidence.RequiredTool = ""
evidence.WorkflowHint = ""
evidence.Reason = reasonPrefix + evidence.Reason
return evidence
}
func applyTeamPermissionGate(input Input, result Result) Result {
if result.Decision != DecisionTeam {
return result
}
if result.WorkflowHint == "" {
result.WorkflowHint = workflowHintForInput(input)
}
if input.Mode != ModeTeam {
return result
}
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role == "" || role == "lead" || input.CanAssignTeamTasks {
return result
}
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return result
}
result.Decision = DecisionSelf
result.RequiredTool = ""
result.WorkflowHint = ""
reason := "member lacks auto-dispatch team request permission"
if !input.MemberRequestsEnabled {
reason = "member lacks team request permission"
}
if strings.TrimSpace(result.Reason) == "" {
result.Reason = reason
} else {
result.Reason = reason + ": " + result.Reason
}
return result
}
func workflowHintForInput(input Input) string {
switch input.Mode {
case ModeDelegate:
return "Use the `delegate` tool with an available linked agent. Do not invent agent keys."
case ModeTeam:
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role != "" && role != "lead" && !input.CanAssignTeamTasks {
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return `As a team member, do not create or assign general team tasks. Use team_tasks(action="create", task_type="request", ...) to ask a teammate for help. This team's member requests auto-dispatch to the assignee after creation.`
}
if input.MemberRequestsEnabled {
return "As a team member, member requests are enabled but auto-dispatch is disabled; choose self because request tasks would stay pending for leader review and may not run in this turn."
}
return "As a team member, you cannot create or assign general team tasks, and member request tasks are disabled. Handle the request directly or explain that a lead must coordinate the team work."
}
return `Use team_tasks(action="search" or "list") first, then team_tasks(action="create", ...) to assign work to an appropriate team member when new team work is required.`
default:
return ""
}
}
func BuildArbiterMessages(input Input, evidence Result) []providers.Message {
system := `You are a Team Work routing arbiter.
Return ONLY JSON. Do not answer the user.
Choose exactly one decision:
- self: the current agent should handle the request directly.
- team: the current agent must use the available team/delegate workflow.
Never return ask. If uncertain, choose self so normal chat is not disrupted.
Strict routing policy:
- Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files, documents, results, or prior team outputs.
- Choose self when the current agent can answer by using existing files, existing task results, or already completed team work without assigning new work.
- Do not choose team only because files are located in the team workspace.
- Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output.
- Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work.
- Choose team when the request clearly needs new work from multiple roles, not merely synthesis of existing material.
- Permission matters: a team member who is not lead cannot assign or create general team tasks.
- If the current agent is a member and member requests are disabled, choose self for requests to assign, split, coordinate, or ask teammates.
- If the current agent is a member and member requests are enabled but auto-dispatch is disabled, choose self because pending leader review is not an immediate executable workflow.
- If the current agent is a member and member requests plus auto-dispatch are enabled, choose team only for a request-help workflow using task_type="request"; do not treat it as lead-style assignment.
- When self_score and collaboration_score are close, choose self unless there is a clear team signal.`
var b strings.Builder
b.WriteString("User request:\n")
b.WriteString(input.Message)
b.WriteString("\n\nRouting mode: ")
b.WriteString(string(input.Mode))
b.WriteString("\nRequired workflow tool when team is chosen: ")
b.WriteString(requiredToolForMode(input.Mode))
if input.Mode == ModeTeam {
b.WriteString("\n\nTeam permission context:\n")
b.WriteString("current_agent_team_role: ")
b.WriteString(firstNonEmpty(input.TeamRole, "unknown"))
b.WriteString("\ncan_assign_team_tasks: ")
b.WriteString(fmt.Sprintf("%t", input.CanAssignTeamTasks))
b.WriteString("\nmember_requests_enabled: ")
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsEnabled))
b.WriteString("\nmember_requests_auto_dispatch: ")
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsAutoDispatch))
if hint := workflowHintForInput(input); hint != "" {
b.WriteString("\nworkflow_hint: ")
b.WriteString(hint)
}
}
b.WriteString("\n\nEmbedding evidence:\n")
b.WriteString(fmt.Sprintf("self_score: %.4f\n", evidence.SelfScore))
b.WriteString(fmt.Sprintf("collaboration_score: %.4f\n", evidence.CollaborationScore))
b.WriteString("embedding_fallback_decision: ")
b.WriteString(string(evidence.Decision))
b.WriteString("\n\nCurrent agent and direct capability:\n")
for _, doc := range appendProfileDocs(input.CurrentAgent, input.SelfTools) {
b.WriteString("---\n")
b.WriteString(doc)
b.WriteString("\n")
}
b.WriteString("\nTeam/delegate/tool capability:\n")
collaborationProfiles := append([]Profile{}, input.Members...)
collaborationProfiles = append(collaborationProfiles, input.Delegates...)
collaborationProfiles = append(collaborationProfiles, input.CollaborationTools...)
for _, doc := range appendProfileDocs(input.Team, collaborationProfiles) {
b.WriteString("---\n")
b.WriteString(doc)
b.WriteString("\n")
}
b.WriteString("\nReturn JSON shape:\n")
b.WriteString(`{"decision":"self|team","confidence":0.0,"mode":"team|delegate","required_tool":"team_tasks|delegate","workflow_hint":"short permission-aware tool guidance","reason":"short internal reason"}`)
return []providers.Message{
{Role: "system", Content: system},
{Role: "user", Content: b.String()},
}
}
func appendProfileDocs(first Profile, rest []Profile) []string {
var docs []string
if doc := renderProfile(first); doc != "" {
docs = append(docs, doc)
}
for _, p := range rest {
if doc := renderProfile(p); doc != "" {
docs = append(docs, doc)
}
}
return docs
}
func ParseArbiterResult(content string, mode Mode) (Result, error) {
raw := strings.TrimSpace(content)
if start := strings.Index(raw, "{"); start >= 0 {
if end := strings.LastIndex(raw, "}"); end >= start {
raw = raw[start : end+1]
}
}
var parsed struct {
Decision string `json:"decision"`
Confidence float64 `json:"confidence"`
Mode string `json:"mode"`
RequiredTool string `json:"required_tool"`
WorkflowHint string `json:"workflow_hint"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return Result{}, err
}
resultMode := Mode(strings.TrimSpace(parsed.Mode))
if resultMode == "" || resultMode == ModeSpawn {
resultMode = mode
}
requiredTool := strings.TrimSpace(parsed.RequiredTool)
if requiredTool == "" {
requiredTool = requiredToolForMode(resultMode)
}
switch Decision(strings.ToLower(strings.TrimSpace(parsed.Decision))) {
case DecisionTeam:
if resultMode != ModeTeam && resultMode != ModeDelegate {
resultMode = mode
}
if resultMode != ModeTeam && resultMode != ModeDelegate {
return Result{Decision: DecisionSelf, Mode: mode, Reason: "arbiter requested team without workflow mode"}, nil
}
return Result{
Decision: DecisionTeam,
Confidence: parsed.Confidence,
Reason: strings.TrimSpace(parsed.Reason),
Mode: resultMode,
RequiredTool: requiredTool,
WorkflowHint: strings.TrimSpace(parsed.WorkflowHint),
}, nil
case DecisionSelf:
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: strings.TrimSpace(parsed.Reason), Mode: mode}, nil
default:
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: "arbiter returned unsupported decision; defaulting to self", Mode: mode}, nil
}
}
func requiredToolForMode(mode Mode) string {
switch mode {
case ModeTeam:
return "team_tasks"
case ModeDelegate:
return "delegate"
default:
return ""
}
}
func looksCasualOrSmallDirect(message string) bool {
s := strings.ToLower(strings.TrimSpace(message))
if s == "" {
return true
}
actionMarkers := []string{
"hãy ", "hay ", "viết", "viet", "tạo", "tao", "làm", "lam",
"kiểm tra", "kiem tra", "phân tích", "phan tich", "soạn", "soan",
"dịch", "dich", "tìm", "tim", "lập kế hoạch", "lap ke hoach",
"triển khai", "trien khai", "thiết kế", "thiet ke", "sửa", "sua",
"đánh giá", "danh gia", "tóm tắt", "tom tat", "nghiên cứu", "nghien cuu",
"check", "create", "write", "analyze", "analyse", "fix", "build",
"plan", "research", "design", "review", "summarize", "summarise",
"查", "写", "创建", "分析", "修复", "设计", "总结",
"작성", "생성", "분석", "수정", "설계", "요약",
}
for _, marker := range actionMarkers {
if strings.Contains(s, marker) {
return false
}
}
casualMarkers := []string{
"chào", "chao", "hello", "hi", "ok", "ừ", "uh", "cảm ơn", "cam on",
"thanks", "thank you", "xin lỗi", "sorry", "được rồi", "duoc roi",
}
for _, marker := range casualMarkers {
if strings.Contains(s, marker) {
return true
}
}
return len([]rune(s)) <= 80
}
func BuildProfileDocuments(input Input) []string {
selfDocs, collaborationDocs := splitProfileDocuments(input)
return append(selfDocs, collaborationDocs...)
}
func splitProfileDocuments(input Input) ([]string, []string) {
var selfDocs []string
if doc := renderProfile(input.CurrentAgent); doc != "" {
selfDocs = append(selfDocs, doc)
}
for _, p := range input.SelfTools {
if doc := renderProfile(p); doc != "" {
selfDocs = append(selfDocs, doc)
}
}
var collaborationDocs []string
if doc := renderProfile(input.Team); doc != "" {
collaborationDocs = append(collaborationDocs, doc)
}
for _, group := range [][]Profile{input.Members, input.Delegates, input.CollaborationTools} {
for _, p := range group {
if doc := renderProfile(p); doc != "" {
collaborationDocs = append(collaborationDocs, doc)
}
}
}
return selfDocs, collaborationDocs
}
func renderProfile(p Profile) string {
name := strings.TrimSpace(p.Name)
text := strings.TrimSpace(p.Text)
kind := strings.TrimSpace(p.Kind)
if name == "" && text == "" {
return ""
}
var b strings.Builder
if kind != "" {
b.WriteString("kind: ")
b.WriteString(kind)
b.WriteString("\n")
}
if name != "" {
b.WriteString("name: ")
b.WriteString(name)
b.WriteString("\n")
}
if text != "" {
b.WriteString("description: ")
b.WriteString(text)
}
return b.String()
}
func bestCosine(query []float32, docs [][]float32) float64 {
best := -1.0
for _, doc := range docs {
score, err := cosine(query, doc)
if err == nil && score > best {
best = score
}
}
if best < 0 {
return 0
}
return best
}
func cosine(a, b []float32) (float64, error) {
if len(a) == 0 || len(a) != len(b) {
return 0, errors.New("dimension mismatch")
}
var dot, na, nb float64
for i := range a {
av := float64(a[i])
bv := float64(b[i])
dot += av * bv
na += av * av
nb += bv * bv
}
if na == 0 || nb == 0 {
return 0, nil
}
return dot / (math.Sqrt(na) * math.Sqrt(nb)), nil
}
@@ -0,0 +1,434 @@
package teamworkclassify
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type fakeEmbedder map[string][]float32
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, 0, len(texts))
for _, text := range texts {
var vec []float32
for key, v := range f {
if strings.Contains(text, key) {
vec = v
break
}
}
if vec == nil {
vec = []float32{0, 0}
}
out = append(out, vec)
}
return out, nil
}
func TestClassifySpawnModeSkipsToSelf(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeSpawn,
Message: "lập kế hoạch content và phân tích chiến lược",
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
}
if result.Reason == "" {
t.Fatal("Reason is empty")
}
}
func TestClassifyTeamWhenRequestMatchesTeamMemberOrToolProfile(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và phân tích chiến lược cho chiến dịch mới",
CurrentAgent: Profile{
Kind: "agent",
Name: "Bảo An",
Text: "điều phối chung",
},
SelfTools: []Profile{
{Kind: "tool", Name: "self chat", Text: "trả lời trò chuyện nhanh"},
},
Team: Profile{
Kind: "team",
Name: "Growth Team",
Text: "team content chiến lược chiến dịch",
},
Members: []Profile{
{Kind: "member", Name: "Bảo Ly Content", Text: "content bài viết kịch bản truyền thông"},
{Kind: "member", Name: "Bảo Ly Chiến lược", Text: "chiến lược kế hoạch phân tích"},
},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "chia việc giao task cho thành viên team"},
},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"chiến lược": {1, 0},
"điều phối": {0, 1},
"trò chuyện": {0, 1},
},
})
if result.Decision != DecisionTeam {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionTeam, result)
}
if result.CollaborationScore <= result.SelfScore {
t.Fatalf("CollaborationScore %.3f must be greater than SelfScore %.3f", result.CollaborationScore, result.SelfScore)
}
}
func TestClassifySelfWhenCurrentAgentToolsAreBestMatch(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeDelegate,
Message: "dịch nhanh câu này sang tiếng Anh",
CurrentAgent: Profile{
Kind: "agent",
Name: "Translator",
Text: "dịch thuật trả lời ngắn",
},
SelfTools: []Profile{
{Kind: "tool", Name: "translate", Text: "dịch nhanh văn bản"},
},
Delegates: []Profile{
{Kind: "delegate", Name: "Planner", Text: "lập kế hoạch dự án dài"},
},
Embedder: fakeEmbedder{
"dịch nhanh": {1, 0},
"dịch thuật": {1, 0},
"lập kế hoạch": {0, 1},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
}
func TestClassifyDefaultsToSelfWhenScoresAreClose(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: "phân tích giúp việc này nên xử lý theo cá nhân hay theo team",
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
Embedder: fakeEmbedder{
"phân tích": {1, 0},
"xử lý yêu cầu chung": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; scores %.3f %.3f", result.Decision, DecisionSelf, result.SelfScore, result.CollaborationScore)
}
}
func TestClassifySelfForCasualOrWeakCloseMessages(t *testing.T) {
cases := []struct {
name string
message string
}{
{name: "casual greeting", message: "chào em"},
{name: "weak close scores", message: "anh hỏi thêm chút thôi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: tc.message,
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
Embedder: fakeEmbedder{
"xử lý yêu cầu chung": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
})
}
}
func TestClassifyWithLLMUsesLongerDefaultArbiterTimeout(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"self","confidence":0.5,"mode":"team","reason":"direct enough"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
}
if provider.deadlineRemaining < 25*time.Second {
t.Fatalf("arbiter deadline remaining = %s, want at least 25s", provider.deadlineRemaining)
}
}
func TestClassifyWithLLMFallsBackToSelfOnArbiterError(t *testing.T) {
provider := &fakeArbiterProvider{err: errors.New("provider unavailable")}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
if !strings.Contains(result.Reason, "arbiter_failed") {
t.Fatalf("Reason = %q, want arbiter_failed marker", result.Reason)
}
}
type fakeArbiterProvider struct {
content string
err error
req providers.ChatRequest
deadlineRemaining time.Duration
}
func (p *fakeArbiterProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.req = req
if deadline, ok := ctx.Deadline(); ok {
p.deadlineRemaining = time.Until(deadline)
}
if p.err != nil {
return nil, p.err
}
return &providers.ChatResponse{Content: p.content}, nil
}
func (p *fakeArbiterProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return nil, nil
}
func (p *fakeArbiterProvider) DefaultModel() string { return "fake-model" }
func (p *fakeArbiterProvider) Name() string { return "fake-provider" }
func TestParseArbiterResultAcceptsSelfOrTeamOnly(t *testing.T) {
team, err := ParseArbiterResult(`{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"needs members"}`, ModeTeam)
if err != nil {
t.Fatalf("ParseArbiterResult(team) error = %v", err)
}
if team.Decision != DecisionTeam || team.RequiredTool != "team_tasks" || team.Mode != ModeTeam {
t.Fatalf("team result = %+v", team)
}
self, err := ParseArbiterResult(`{"decision":"ask","confidence":0.55,"mode":"team","reason":"unclear"}`, ModeTeam)
if err != nil {
t.Fatalf("ParseArbiterResult(unsupported decision fallback) error = %v", err)
}
if self.Decision != DecisionSelf {
t.Fatalf("unsupported decision must fall back to self, got %+v", self)
}
}
func TestClassifyWithLLMUsesArbiterDecisionAndEvidence(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.86,"mode":"team","required_tool":"team_tasks","reason":"requires content and strategy members"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{
{Kind: "team_member", Name: "Bảo Ly Content", Text: "content"},
{Kind: "team_member", Name: "Bảo Ly Chiến lược", Text: "chiến lược"},
},
CollaborationTools: []Profile{{Kind: "tool", Name: "team_tasks", Text: "assign team work"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"chiến lược": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
t.Fatalf("ClassifyWithLLM result = %+v", result)
}
if len(provider.req.Messages) < 2 {
t.Fatalf("arbiter request messages missing: %+v", provider.req)
}
joined := provider.req.Messages[0].Content + "\n" + provider.req.Messages[1].Content
for _, want := range []string{"Return ONLY JSON", "Bảo An", "Growth Team", "team_tasks", "self_score", "collaboration_score"} {
if !strings.Contains(joined, want) {
t.Fatalf("arbiter prompt missing %q:\n%s", want, joined)
}
}
}
func TestClassifyWithLLMForcesSelfWhenMemberCannotAssignOrRequest(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"route to teammate"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "nhờ thành viên khác làm giúp phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "shared work"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: false,
Embedder: fakeEmbedder{
"nhờ thành viên": {1, 0},
"shared work": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want self for member without request permission; result=%+v", result.Decision, result)
}
if result.RequiredTool != "" {
t.Fatalf("RequiredTool = %q, want empty", result.RequiredTool)
}
if !strings.Contains(result.Reason, "member lacks team request permission") {
t.Fatalf("Reason = %q, want member permission marker", result.Reason)
}
}
func TestClassifyWithLLMForcesSelfWhenMemberRequestNeedsLeaderReview(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: false,
Embedder: fakeEmbedder{
"request": {1, 0},
"content team": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want self when member request does not auto-dispatch; result=%+v", result.Decision, result)
}
if !strings.Contains(result.Reason, "member lacks auto-dispatch team request permission") {
t.Fatalf("Reason = %q, want auto-dispatch permission marker", result.Reason)
}
}
func TestClassifyWithLLMAllowsAutoDispatchMemberRequest(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: true,
Embedder: fakeEmbedder{
"request": {1, 0},
"content team": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
t.Fatalf("ClassifyWithLLM result = %+v, want team/team_tasks", result)
}
if !strings.Contains(result.WorkflowHint, `task_type="request"`) {
t.Fatalf("WorkflowHint = %q, want task_type=request guidance", result.WorkflowHint)
}
if !strings.Contains(result.WorkflowHint, "auto-dispatch") {
t.Fatalf("WorkflowHint = %q, want auto-dispatch guidance", result.WorkflowHint)
}
}
func TestBuildArbiterMessagesIncludesStrictTeamRoutingPolicy(t *testing.T) {
messages := BuildArbiterMessages(Input{
Mode: ModeTeam,
Message: "em đọc cả 2 file rồi diễn giải lại cho anh về phương án em chọn",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "lead and synthesize existing team outputs"},
Team: Profile{Kind: "team", Name: "Strategy Team", Text: "team research and execution"},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "search existing tasks, create tasks, assign work"},
{Kind: "capability", Name: "shared team workspace", Text: "files from previous team work"},
},
}, Result{
Decision: DecisionSelf,
SelfScore: 0.6341,
CollaborationScore: 0.6362,
})
if len(messages) < 2 {
t.Fatalf("messages = %+v, want system and user messages", messages)
}
prompt := messages[0].Content + "\n" + messages[1].Content
for _, want := range []string{
"Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files",
"Do not choose team only because files are located in the team workspace",
"Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output",
"Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work",
"When self_score and collaboration_score are close, choose self unless there is a clear team signal",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("arbiter prompt missing policy %q:\n%s", want, prompt)
}
}
}
func TestBuildArbiterMessagesIncludesTeamPermissionContext(t *testing.T) {
messages := BuildArbiterMessages(Input{
Mode: ModeTeam,
Message: "nhờ thành viên khác hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "member role"},
Team: Profile{Kind: "team", Name: "Team", Text: "team work"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: false,
}, Result{Decision: DecisionSelf})
prompt := messages[0].Content + "\n" + messages[1].Content
for _, want := range []string{
"Team permission context",
"current_agent_team_role: member",
"can_assign_team_tasks: false",
"member_requests_enabled: true",
"member_requests_auto_dispatch: false",
"choose self",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("arbiter prompt missing %q:\n%s", want, prompt)
}
}
}
func TestBuildProfileDocumentsIncludesTeamLinksAndTools(t *testing.T) {
input := Input{
Mode: ModeTeam,
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "lead"},
SelfTools: []Profile{{Kind: "tool", Name: "web_search", Text: "search"}},
Team: Profile{Kind: "team", Name: "Team A", Text: "team"},
Members: []Profile{{Kind: "member", Name: "Member A", Text: "member"}},
Delegates: []Profile{{Kind: "delegate", Name: "Delegate A", Text: "delegate"}},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "team task board"},
},
}
docs := BuildProfileDocuments(input)
joined := strings.Join(docs, "\n")
for _, want := range []string{"Lead", "web_search", "Team A", "Member A", "Delegate A", "team_tasks"} {
if !strings.Contains(joined, want) {
t.Fatalf("profile documents missing %q:\n%s", want, joined)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package teamworkclassify
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type ProfileStores struct {
Agents store.AgentStore
Teams store.TeamStore
AgentLinks store.AgentLinkStore
}
type BuildInputOptions struct {
Mode Mode
Message string
AgentID uuid.UUID
ToolAllow []string
SkillFilter []string
Embedder Embedder
ExtraSelf []Profile
ExtraCollab []Profile
}
func BuildInputFromStores(ctx context.Context, stores ProfileStores, opts BuildInputOptions) Input {
input := Input{
Mode: opts.Mode,
Message: opts.Message,
Embedder: opts.Embedder,
}
if opts.Mode == "" || opts.Mode == ModeSpawn || opts.AgentID == uuid.Nil {
return input
}
if stores.Agents != nil {
if ag, err := stores.Agents.GetByID(ctx, opts.AgentID); err == nil && ag != nil {
input.CurrentAgent = Profile{
Kind: "agent",
Name: firstNonEmpty(ag.DisplayName, ag.AgentKey),
Text: strings.TrimSpace(strings.Join([]string{
ag.Frontmatter,
ag.AgentDescription,
}, "\n")),
}
}
}
input.SelfTools = append(input.SelfTools, effectiveSelfToolProfiles(opts.ToolAllow, opts.SkillFilter)...)
input.SelfTools = append(input.SelfTools, opts.ExtraSelf...)
if opts.Mode == ModeTeam && stores.Teams != nil {
if team, err := stores.Teams.GetTeamForAgent(ctx, opts.AgentID); err == nil && team != nil {
input.TeamRole = "member"
if team.LeadAgentID == opts.AgentID {
input.TeamRole = "lead"
input.CanAssignTeamTasks = true
}
memberRequestCfg := parseMemberRequestRoutingConfig(team.Settings)
input.MemberRequestsEnabled = memberRequestCfg.Enabled
input.MemberRequestsAutoDispatch = memberRequestCfg.AutoDispatch
input.Team = Profile{
Kind: "team",
Name: team.Name,
Text: strings.TrimSpace(strings.Join([]string{
team.Description,
fmt.Sprintf("lead_agent: %s %s", team.LeadAgentKey, team.LeadDisplayName),
}, "\n")),
}
if members, err := stores.Teams.ListMembers(ctx, team.ID); err == nil {
for _, member := range members {
if member.AgentID == opts.AgentID && !input.CanAssignTeamTasks {
if strings.TrimSpace(member.Role) != "" {
input.TeamRole = member.Role
}
}
input.Members = append(input.Members, Profile{
Kind: "team_member",
Name: firstNonEmpty(member.DisplayName, member.AgentKey),
Text: strings.TrimSpace(strings.Join([]string{
"role: " + member.Role,
"agent_key: " + member.AgentKey,
member.Frontmatter,
}, "\n")),
})
}
}
input.CollaborationTools = append(input.CollaborationTools, teamPermissionProfiles(input)...)
}
}
if stores.AgentLinks != nil {
if links, err := stores.AgentLinks.DelegateTargets(ctx, opts.AgentID); err == nil {
for _, link := range links {
input.Delegates = append(input.Delegates, Profile{
Kind: "delegate",
Name: firstNonEmpty(link.TargetDisplayName, link.TargetAgentKey),
Text: strings.TrimSpace(strings.Join([]string{
"agent_key: " + link.TargetAgentKey,
"link_direction: " + link.Direction,
"link_team: " + link.TeamName,
"target_team: " + link.TargetTeamName,
fmt.Sprintf("target_is_team_lead: %t", link.TargetIsTeamLead),
link.Description,
link.TargetDescription,
}, "\n")),
})
}
if opts.Mode == ModeDelegate && len(links) > 0 {
input.CollaborationTools = append(input.CollaborationTools,
Profile{Kind: "tool", Name: "delegate", Text: "delegate work to linked agents with matching expertise and receive their result"},
)
}
}
}
input.CollaborationTools = append(input.CollaborationTools, opts.ExtraCollab...)
return input
}
type memberRequestRoutingConfig struct {
Enabled bool
AutoDispatch bool
}
func parseMemberRequestRoutingConfig(settings json.RawMessage) memberRequestRoutingConfig {
var cfg memberRequestRoutingConfig
if len(settings) == 0 {
return cfg
}
var raw struct {
MemberRequests *struct {
Enabled *bool `json:"enabled"`
AutoDispatch *bool `json:"auto_dispatch"`
} `json:"member_requests"`
}
if json.Unmarshal(settings, &raw) != nil || raw.MemberRequests == nil {
return cfg
}
if raw.MemberRequests.Enabled != nil {
cfg.Enabled = *raw.MemberRequests.Enabled
}
if raw.MemberRequests.AutoDispatch != nil {
cfg.AutoDispatch = *raw.MemberRequests.AutoDispatch
}
return cfg
}
func teamPermissionProfiles(input Input) []Profile {
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role == "" || role == "lead" || input.CanAssignTeamTasks {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "lead can search existing tasks, create tasks, assign work to team members, track progress, review and complete team work"},
{Kind: "tool", Name: "ask_user", Text: "ask the user for decisions or missing information during team work"},
{Kind: "capability", Name: "shared team workspace", Text: "coordinate multi-step work through shared files, task board, and team member results"},
}
}
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: `member cannot assign general tasks; member may create task_type="request" to ask another teammate for help; requests auto-dispatch to the assignee`},
{Kind: "capability", Name: "member request workflow", Text: "request help from teammates without lead-style assignment authority"},
}
}
if input.MemberRequestsEnabled {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "member request tasks are enabled but auto-dispatch is disabled; requests stay pending for leader review and should not be used as immediate routed workflow"},
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
}
}
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "member cannot create or assign general tasks; member request tasks are disabled; use comments/progress/current-task actions only"},
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
}
}
func effectiveSelfToolProfiles(toolAllow, skillFilter []string) []Profile {
var profiles []Profile
if len(toolAllow) > 0 {
profiles = append(profiles, Profile{
Kind: "tool_allow",
Name: "channel allowed tools",
Text: "effective channel/group allowed tools: " + strings.Join(toolAllow, ", "),
})
} else {
profiles = append(profiles, Profile{
Kind: "tool_allow",
Name: "default tools",
Text: "current agent may use its configured tools for direct small tasks",
})
}
if len(skillFilter) > 0 {
profiles = append(profiles, Profile{
Kind: "skill_filter",
Name: "topic skills",
Text: "effective topic skill filter: " + strings.Join(skillFilter, ", "),
})
}
return profiles
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}