mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-17 04:15:05 +00:00
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:
1 parent
f31214f5ce
commit
f826738ee6
34 files changed
+1715
-57
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
Reference in new issue
Block a user