From f826738ee6a5cb0643f7d446d04c98cd03460807 Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Tue, 7 Jul 2026 01:21:12 +0700 Subject: [PATCH] feat: add team work classification routing (#1379) Approved by github-maintain bot. Clean team work classification feature with comprehensive tests and i18n. --- cmd/gateway.go | 5 +- cmd/gateway_consumer.go | 5 +- cmd/gateway_consumer_deps.go | 3 + cmd/gateway_consumer_normal.go | 10 +- cmd/gateway_deps.go | 2 + cmd/gateway_lifecycle.go | 2 +- cmd/gateway_methods.go | 4 +- cmd/gateway_setup.go | 6 +- cmd/gateway_system_config_sync.go | 1 + cmd/gateway_team_work_classify.go | 66 +++ cmd/gateway_team_work_classify_test.go | 65 +++ internal/agent/loop_pipeline_callbacks.go | 32 +- internal/agent/loop_types.go | 1 + internal/agent/team_work_directive.go | 100 ++++ internal/agent/team_work_directive_test.go | 55 ++ internal/config/config_channels.go | 1 + internal/config/config_system.go | 1 + internal/gateway/methods/chat.go | 102 +++- internal/teamworkclassify/classifier.go | 546 ++++++++++++++++++ internal/teamworkclassify/classifier_test.go | 434 ++++++++++++++ internal/teamworkclassify/context.go | 209 +++++++ .../layout/system-settings-constants.ts | 2 + .../layout/system-settings-modal.tsx | 7 +- .../shared/feature-switch-group.tsx | 12 +- ui/web/src/i18n/locales/en/config.json | 10 +- .../src/i18n/locales/en/system-settings.json | 10 +- ui/web/src/i18n/locales/ko/config.json | 10 +- .../src/i18n/locales/ko/system-settings.json | 10 +- ui/web/src/i18n/locales/vi/config.json | 10 +- .../src/i18n/locales/vi/system-settings.json | 10 +- ui/web/src/i18n/locales/zh/config.json | 10 +- .../src/i18n/locales/zh/system-settings.json | 10 +- .../config/sections/behavior-section.tsx | 3 + .../config/sections/behavior-ux-card.tsx | 18 +- 34 files changed, 1715 insertions(+), 57 deletions(-) create mode 100644 cmd/gateway_team_work_classify.go create mode 100644 cmd/gateway_team_work_classify_test.go create mode 100644 internal/agent/team_work_directive.go create mode 100644 internal/agent/team_work_directive_test.go create mode 100644 internal/teamworkclassify/classifier.go create mode 100644 internal/teamworkclassify/classifier_test.go create mode 100644 internal/teamworkclassify/context.go diff --git a/cmd/gateway.go b/cmd/gateway.go index 4592b3ca..c41e90bc 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -372,7 +372,7 @@ func runGateway() { slog.Debug("skipping MCP database init: pgStores.MCP is nil") } - setupMemoryEmbeddings(pgStores, providerRegistry) + teamWorkEmbedder := setupMemoryEmbeddings(pgStores, providerRegistry) usageCapSvc := usagecaps.NewService(pgStores.UsageCaps, pgStores.Providers) // Resolve background provider for consolidation + vault enrichment. @@ -532,6 +532,7 @@ func runGateway() { domainBus: domainBus, usageCapSvc: usageCapSvc, audioMgr: audioMgr, + teamWorkEmbedder: teamWorkEmbedder, } gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) @@ -639,7 +640,7 @@ func runGateway() { // Register all RPC methods server.SetLogTee(logTee) server.SetRuntimeLogsHandler(httpapi.NewRuntimeLogsHandler(logTee)) - pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Tracing, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc, providerRegistry) + pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Tracing, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, pgStores.AgentLinks, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc, providerRegistry, teamWorkEmbedder) // Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history). if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil { diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 7268d968..c5651cb4 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -15,6 +15,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/scheduler" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -27,7 +28,7 @@ import ( // and routes them through the scheduler/agent loop, then publishes the response back. // Also handles subagent announcements: routes them through the parent agent's session // (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user. -func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry) { +func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, agentLinkStore store.AgentLinkStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry, teamWorkEmbedder memory.EmbeddingProvider) { slog.Info("inbound message consumer started") // Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts). @@ -53,6 +54,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents ChannelMgr: channelMgr, MsgBus: msgBus, TeamStore: teamStore, + AgentLinkStore: agentLinkStore, AgentStore: agentStore, SessStore: sessStore, PostTurn: postTurn, @@ -61,6 +63,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents SubagentMgr: subagentMgr, UsageCaps: usageCapSvc, ProviderReg: providerReg, + TeamWorkEmbedder: teamWorkEmbedder, GetAnnounceMu: getAnnounceMu, } diff --git a/cmd/gateway_consumer_deps.go b/cmd/gateway_consumer_deps.go index bbec2130..184ec4e4 100644 --- a/cmd/gateway_consumer_deps.go +++ b/cmd/gateway_consumer_deps.go @@ -7,6 +7,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/scheduler" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -23,6 +24,7 @@ type ConsumerDeps struct { ChannelMgr *channels.Manager MsgBus *bus.MessageBus TeamStore store.TeamStore + AgentLinkStore store.AgentLinkStore AgentStore store.AgentStore SessStore store.SessionStore PostTurn tools.PostTurnProcessor @@ -32,6 +34,7 @@ type ConsumerDeps struct { SubagentMgr *tools.SubagentManager UsageCaps *usagecaps.Service ProviderReg *providers.Registry + TeamWorkEmbedder memory.EmbeddingProvider BgWg sync.WaitGroup GetAnnounceMu func(string) *sync.Mutex } diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index f7516eeb..2fc24fbf 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -407,11 +407,16 @@ func processNormalMessage( } } + inboundMessage := msg.Content + // Inject tenant context from channel instance so all store queries are tenant-scoped. if msg.TenantID != uuid.Nil { ctx = store.WithTenantID(ctx, msg.TenantID) } + gate := applyTeamWorkGateForInbound(ctx, deps, msg, sessionKey, agentID, peerKind, agentLoop.UUID(), skillFilter, agentLoop.Provider(), agentLoop.Model()) + inboundMessage = gate.Message + // Inject post-turn dispatch tracker so team task creates are deferred. ptd := tools.NewPendingTeamDispatch() schedCtx := tools.WithPendingTeamDispatch(ctx, ptd) @@ -447,7 +452,7 @@ func processNormalMessage( // Schedule through main lane (per-session concurrency controlled by maxConcurrent) outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{ SessionKey: sessionKey, - Message: msg.Content, + Message: inboundMessage, Media: reqMedia, ForwardMedia: fwdMedia, Channel: msg.Channel, @@ -471,6 +476,7 @@ func processNormalMessage( ToolAllow: msg.ToolAllow, TelegramManagerPermissions: msg.TelegramManagerPermissions, ExtraSystemPrompt: extraPrompt, + TeamWorkDirective: gate.Directive, SkillFilter: skillFilter, }, scheduler.ScheduleOpts{ MaxConcurrent: maxConcurrent, @@ -615,7 +621,7 @@ func processNormalMessage( if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard { go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent) } - }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) + }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, inboundMessage, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig()) } func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, userID, peerKind, channelType, agentKey string) channels.DeliveryRuntime { diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go index c30ca4f2..bc0e2f22 100644 --- a/cmd/gateway_deps.go +++ b/cmd/gateway_deps.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/eventbus" "github.com/nextlevelbuilder/goclaw/internal/gateway" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -39,4 +40,5 @@ type gatewayDeps struct { usageCapSvc *usagecaps.Service audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler ttsHandler *httpapi.TTSHandler // nil if TTS not configured; for hot-reload + teamWorkEmbedder memory.EmbeddingProvider } diff --git a/cmd/gateway_lifecycle.go b/cmd/gateway_lifecycle.go index fd15dedf..1cc97ee8 100644 --- a/cmd/gateway_lifecycle.go +++ b/cmd/gateway_lifecycle.go @@ -147,7 +147,7 @@ func (d *gatewayDeps) runLifecycle( d.channelMgr.SetContactCollector(contactCollector) } - go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc, d.providerRegistry) + go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, d.pgStores.AgentLinks, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc, d.providerRegistry, d.teamWorkEmbedder) // Webhook callback worker — delivers async webhook_calls rows to receiver callback_url. // Runs in both editions: Standard (PG, concurrency=4) and Lite (SQLite, concurrency=1). diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 15085d6c..afd3f4da 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -10,19 +10,21 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps" ) -func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, tracingStore store.TracingStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) { +func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, tracingStore store.TracingStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, agentLinkStore store.AgentLinkStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry, teamWorkEmbedder memory.EmbeddingProvider) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) { router := server.Router() // Phase 1: Core methods chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus) chatMethods.SetAudioManager(audioMgr) // Wire TTS auto-apply for WS responses chatMethods.SetUsageCapService(usageCapSvc) + chatMethods.SetTeamWorkClassification(agentStore, teamStore, agentLinkStore, teamWorkEmbedder) chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index fad4c4bd..1facfa49 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -17,6 +17,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/edition" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" @@ -370,9 +371,11 @@ func wireTracingAndCron( func setupMemoryEmbeddings( pgStores *store.Stores, providerRegistry *providers.Registry, -) { +) memory.EmbeddingProvider { + var resolved memory.EmbeddingProvider if pgStores.Memory != nil { if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil { + resolved = embProvider pgStores.Memory.SetEmbeddingProvider(embProvider) slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model()) @@ -431,6 +434,7 @@ func setupMemoryEmbeddings( slog.Warn("memory embeddings disabled (no API key), chunks stored without vectors") } } + return resolved } // seedSystemConfigs ensures system_configs has all expected keys for all tenants. diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index c6be78be..95a49b8a 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -108,6 +108,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg * setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs) setBool("gateway.block_reply", cfg.Gateway.BlockReply) setBool("gateway.tool_status", cfg.Gateway.ToolStatus) + setBool("gateway.team_work_classify", cfg.Gateway.TeamWorkClassify) setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec) // Background workers diff --git a/cmd/gateway_team_work_classify.go b/cmd/gateway_team_work_classify.go new file mode 100644 index 00000000..e2b2136d --- /dev/null +++ b/cmd/gateway_team_work_classify.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/teamworkclassify" +) + +type teamWorkGateOutcome struct { + Message string + Directive *agent.TeamWorkDirective +} + +func applyTeamWorkGateForInbound(ctx context.Context, deps *ConsumerDeps, msg bus.InboundMessage, sessionKey, agentKey, peerKind string, agentUUID uuid.UUID, skillFilter []string, provider providers.Provider, model string) teamWorkGateOutcome { + out := teamWorkGateOutcome{Message: msg.Content} + if deps == nil || deps.Cfg == nil || deps.Cfg.Gateway.TeamWorkClassify == nil || !*deps.Cfg.Gateway.TeamWorkClassify { + return out + } + if deps.TeamWorkEmbedder == nil { + slog.Info("team_work_classify: skipped; embedding unavailable", "agent", agentKey, "session", sessionKey) + return out + } + if agentUUID == uuid.Nil { + return out + } + mode := agent.ResolveOrchestrationMode(ctx, agentUUID, deps.TeamStore, deps.AgentLinkStore) + if mode == agent.ModeSpawn { + slog.Info("team_work_classify: skipped; no team/delegate capability", "agent", agentKey, "session", sessionKey) + return out + } + if msg.Metadata["run_kind"] != "" || msg.Metadata["delegation_id"] != "" || msg.Metadata["subagent_id"] != "" || bus.IsInternalSender(msg.SenderID) { + return out + } + + input := teamworkclassify.BuildInputFromStores(ctx, teamworkclassify.ProfileStores{ + Agents: deps.AgentStore, + Teams: deps.TeamStore, + AgentLinks: deps.AgentLinkStore, + }, teamworkclassify.BuildInputOptions{ + Mode: teamworkclassify.Mode(mode), + Message: msg.Content, + AgentID: agentUUID, + ToolAllow: msg.ToolAllow, + SkillFilter: skillFilter, + Embedder: deps.TeamWorkEmbedder, + }) + result := teamworkclassify.ClassifyWithLLM(ctx, input, provider, model, deps.UsageCaps) + slog.Info("team_work_classify: decision", "agent", agentKey, "session", sessionKey, "mode", mode, "decision", result.Decision, "self_score", result.SelfScore, "collaboration_score", result.CollaborationScore, "reason", result.Reason) + if result.Decision == teamworkclassify.DecisionTeam { + out.Directive = &agent.TeamWorkDirective{ + Mode: string(result.Mode), + Source: "llm", + Reason: result.Reason, + OriginalMessage: msg.Content, + RequiredTool: result.RequiredTool, + WorkflowHint: result.WorkflowHint, + } + } + return out +} diff --git a/cmd/gateway_team_work_classify_test.go b/cmd/gateway_team_work_classify_test.go new file mode 100644 index 00000000..9ce8fa06 --- /dev/null +++ b/cmd/gateway_team_work_classify_test.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +type teamWorkGateTestEmbedder struct { + called bool +} + +func (e *teamWorkGateTestEmbedder) Name() string { return "test-embedder" } +func (e *teamWorkGateTestEmbedder) Model() string { return "test-embedding" } +func (e *teamWorkGateTestEmbedder) Embed(context.Context, []string) ([][]float32, error) { + e.called = true + return [][]float32{{1, 0}}, nil +} + +type teamWorkGateTestProvider struct { + called bool +} + +func (p *teamWorkGateTestProvider) Name() string { return "test-provider" } +func (p *teamWorkGateTestProvider) DefaultModel() string { return "test-model" } +func (p *teamWorkGateTestProvider) Chat(context.Context, providers.ChatRequest) (*providers.ChatResponse, error) { + p.called = true + return &providers.ChatResponse{Content: `{"decision":"team","mode":"team","required_tool":"team_tasks"}`}, nil +} +func (p *teamWorkGateTestProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) { + p.called = true + return nil, nil +} + +func TestApplyTeamWorkGateForInboundSkipsAgentWithoutTeamOrDelegateLink(t *testing.T) { + enabled := true + embedder := &teamWorkGateTestEmbedder{} + provider := &teamWorkGateTestProvider{} + + out := applyTeamWorkGateForInbound(context.Background(), &ConsumerDeps{ + Cfg: &config.Config{Gateway: config.GatewayConfig{TeamWorkClassify: &enabled}}, + TeamWorkEmbedder: embedder, + }, bus.InboundMessage{ + Content: "lập kế hoạch content và chiến lược cho chiến dịch mới", + Metadata: map[string]string{}, + }, "session:test", "bao-an", "direct", uuid.New(), nil, provider, "test-model") + + if out.Message != "lập kế hoạch content và chiến lược cho chiến dịch mới" { + t.Fatalf("Message = %q, want original message", out.Message) + } + if out.Directive != nil { + t.Fatalf("Directive = %+v, want nil", out.Directive) + } + if embedder.called { + t.Fatal("embedder was called even though agent has no team/delegate capability") + } + if provider.called { + t.Fatal("provider was called even though agent has no team/delegate capability") + } +} diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index d430df0d..0c362b2f 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -48,7 +48,7 @@ func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCa loadSessionHistory: l.makeLoadSessionHistory(), resolveWorkspace: l.makeResolveWorkspace(req), loadContextFiles: l.makeLoadContextFiles(), - buildMessages: l.makeBuildMessages(), + buildMessages: l.makeBuildMessages(req), enrichMedia: l.makeEnrichMedia(req), injectReminders: l.makeInjectReminders(req), buildFilteredTools: l.makeBuildFilteredTools(req), @@ -132,8 +132,14 @@ func (l *Loop) makeLoadContextFiles() func(ctx context.Context, userID string) ( } } -func (l *Loop) makeBuildMessages() func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error) { +func (l *Loop) makeBuildMessages(req *RunRequest) func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error) { return func(ctx context.Context, input *pipeline.RunInput, history []providers.Message, summary string) ([]providers.Message, error) { + if prompt := buildTeamWorkDirectivePrompt(req.TeamWorkDirective); prompt != "" { + if input.ExtraSystemPrompt != "" { + input.ExtraSystemPrompt += "\n\n" + } + input.ExtraSystemPrompt += prompt + } msgs, _ := l.buildMessages(ctx, history, summary, input.Message, input.ExtraSystemPrompt, input.SessionKey, input.Channel, input.ChannelType, @@ -497,9 +503,29 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c }(), "tools_provided", len(chatReq.Tools)) + if err == nil && teamWorkDirectiveNeedsRetry(req.TeamWorkDirective, state.Iteration, resp) { + retryReq := buildTeamWorkDirectiveRetryRequest(chatReq, req.TeamWorkDirective) + resp, err = callProvider("team-work-directive-retry", retryReq) + slog.Info("team_work_classify: directive retry response", + "has_error", err != nil, + "required_tool", req.TeamWorkDirective.normalizedRequiredTool(), + "tool_calls_count", func() int { + if resp == nil { + return -1 + } + return len(resp.ToolCalls) + }()) + if err == nil && teamWorkDirectiveNeedsRetry(req.TeamWorkDirective, state.Iteration, resp) { + resp = &providers.ChatResponse{ + Content: teamWorkDirectiveBlocker(req.TeamWorkDirective), + FinishReason: "stop", + } + } + } + // One guarded retry when MCP task tools are available but the model // returns text-only instead of tool calls. - retryEligible := err == nil && resp != nil && len(resp.ToolCalls) == 0 && shouldRetryTaskMCP(chatReq) + retryEligible := req.TeamWorkDirective == nil && err == nil && resp != nil && len(resp.ToolCalls) == 0 && shouldRetryTaskMCP(chatReq) slog.Info("debug.llm.retry_guard", "retry_eligible", retryEligible) if retryEligible { retryReq := chatReq diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 715188f9..ab5d6f8f 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -625,6 +625,7 @@ type RunRequest struct { Role string // caller's RBAC role (admin/operator/viewer/owner); bypasses per-user grants for authenticated admins (#915) Stream bool // whether to stream response chunks ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.) + TeamWorkDirective *TeamWorkDirective // optional: force this turn through team/delegate workflow SkillFilter []string // per-request skill override: nil=use agent default, []=no skills, ["x","y"]=whitelist HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config) ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx") diff --git a/internal/agent/team_work_directive.go b/internal/agent/team_work_directive.go new file mode 100644 index 00000000..e0612947 --- /dev/null +++ b/internal/agent/team_work_directive.go @@ -0,0 +1,100 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +type TeamWorkDirective struct { + Mode string + Source string + Reason string + OriginalMessage string + RequiredTool string + WorkflowHint string +} + +func (d *TeamWorkDirective) normalizedRequiredTool() string { + if d == nil { + return "" + } + if strings.TrimSpace(d.RequiredTool) != "" { + return strings.TrimSpace(d.RequiredTool) + } + switch strings.TrimSpace(d.Mode) { + case "team": + return "team_tasks" + case "delegate": + return "delegate" + default: + return "" + } +} + +func buildTeamWorkDirectivePrompt(d *TeamWorkDirective) string { + tool := d.normalizedRequiredTool() + if tool == "" { + return "" + } + var b strings.Builder + b.WriteString("## TEAM WORK ROUTING LOCK\n") + b.WriteString("This turn has been classified by the system as requiring the team/delegate workflow.\n") + b.WriteString("You must not complete the requested work by yourself before using the required workflow tool.\n") + b.WriteString("Required tool: `") + b.WriteString(tool) + b.WriteString("`.\n") + if d.Mode != "" { + b.WriteString("Workflow mode: ") + b.WriteString(d.Mode) + b.WriteString(".\n") + } + if d.Reason != "" { + b.WriteString("Routing reason: ") + b.WriteString(d.Reason) + b.WriteString(".\n") + } + if d.WorkflowHint != "" { + b.WriteString("Workflow hint: ") + b.WriteString(d.WorkflowHint) + b.WriteString("\n") + } + b.WriteString("If the required workflow tool is unavailable or cannot be used, explain that blocker instead of presenting the work as complete.") + return b.String() +} + +func teamWorkDirectiveNeedsRetry(d *TeamWorkDirective, iteration int, resp *providers.ChatResponse) bool { + tool := d.normalizedRequiredTool() + if tool == "" || iteration != 0 || resp == nil { + return false + } + for _, tc := range resp.ToolCalls { + if tc.Name == tool { + return false + } + } + return true +} + +func buildTeamWorkDirectiveRetryRequest(req providers.ChatRequest, d *TeamWorkDirective) providers.ChatRequest { + retry := req + retry.Options = make(map[string]any, len(req.Options)+1) + for k, v := range req.Options { + retry.Options[k] = v + } + retry.Options[providers.OptToolChoice] = "required" + retry.Messages = append(append([]providers.Message{}, req.Messages...), providers.Message{ + Role: "system", + Content: fmt.Sprintf("The system routing lock requires the `%s` workflow tool in this turn. Call that tool now before giving any final answer. If it is impossible, explain the blocker.", d.normalizedRequiredTool()), + }) + return retry +} + +func teamWorkDirectiveBlocker(d *TeamWorkDirective) string { + tool := d.normalizedRequiredTool() + if tool == "" { + return "Hệ thống đã yêu cầu quy trình team/delegate nhưng không xác định được công cụ cần dùng, nên lượt này chưa thể coi là hoàn tất." + } + return fmt.Sprintf("Hệ thống đã yêu cầu quy trình team/delegate cho lượt này, nhưng model không gọi công cụ `%s` như bắt buộc. Vì vậy hệ thống chưa thể coi nhiệm vụ đã được chuyển đúng workflow.", tool) +} diff --git a/internal/agent/team_work_directive_test.go b/internal/agent/team_work_directive_test.go new file mode 100644 index 00000000..36cf4450 --- /dev/null +++ b/internal/agent/team_work_directive_test.go @@ -0,0 +1,55 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func TestTeamWorkDirectivePromptRequiresWorkflowTool(t *testing.T) { + prompt := buildTeamWorkDirectivePrompt(&TeamWorkDirective{ + Mode: "team", + Source: "llm", + Reason: "requires strategy and content members", + OriginalMessage: "lập kế hoạch chiến dịch", + RequiredTool: "team_tasks", + WorkflowHint: `Use team_tasks(action="create", task_type="request") because this agent is a member requesting help.`, + }) + for _, want := range []string{"TEAM WORK ROUTING LOCK", "team_tasks", "must not complete", "requires strategy and content members", `task_type="request"`} { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestTeamWorkDirectiveRetriesWhenRequiredToolMissing(t *testing.T) { + directive := &TeamWorkDirective{Mode: "team", RequiredTool: "team_tasks"} + if !teamWorkDirectiveNeedsRetry(directive, 0, &providers.ChatResponse{Content: "em tự làm xong rồi"}) { + t.Fatal("text-only first response should require retry") + } + if teamWorkDirectiveNeedsRetry(directive, 1, &providers.ChatResponse{Content: "em tự làm xong rồi"}) { + t.Fatal("second iteration should not retry workflow directive") + } + if teamWorkDirectiveNeedsRetry(directive, 0, &providers.ChatResponse{ToolCalls: []providers.ToolCall{{Name: "team_tasks"}}}) { + t.Fatal("response with required tool should not retry") + } +} + +func TestTeamWorkDirectiveRetryRequestRequiresToolChoice(t *testing.T) { + directive := &TeamWorkDirective{Mode: "delegate", RequiredTool: "delegate"} + req := providers.ChatRequest{ + Messages: []providers.Message{{Role: "user", Content: "làm việc này theo link"}}, + Options: map[string]any{"existing": true}, + } + retry := buildTeamWorkDirectiveRetryRequest(req, directive) + if retry.Options[providers.OptToolChoice] != "required" { + t.Fatalf("tool_choice = %v, want required", retry.Options[providers.OptToolChoice]) + } + if retry.Options["existing"] != true { + t.Fatalf("existing option was not preserved: %+v", retry.Options) + } + if len(retry.Messages) != 2 || !strings.Contains(retry.Messages[1].Content, "delegate") { + t.Fatalf("retry messages not reinforced: %+v", retry.Messages) + } +} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 82cc7725..8ffca30c 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -433,6 +433,7 @@ type GatewayConfig struct { BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false) ChatBehavior *ChatBehaviorConfig `json:"chat_behavior,omitempty"` // human-like channel delivery behavior (default disabled) ToolStatus *bool `json:"tool_status,omitempty"` // show tool name in streaming preview during tool execution (default true) + TeamWorkClassify *bool `json:"team_work_classify,omitempty"` // classify new requests as direct handling or team workflow (default false) TaskRecoveryIntervalSec int `json:"task_recovery_interval_sec,omitempty"` // team task recovery ticker interval in seconds (default 300 = 5min) WebhookAsyncTimeoutSec int `json:"webhook_async_timeout_sec,omitempty"` // async webhook worker agent-run deadline in seconds (default 600, cap 3600) WebhookSyncTimeoutSec int `json:"webhook_sync_timeout_sec,omitempty"` // sync + test webhook handler agent-run deadline in seconds (default 600, cap 3600). NOTE: sync holds the HTTP connection open for this duration — a value above an upstream proxy/LB read timeout may be cut. diff --git a/internal/config/config_system.go b/internal/config/config_system.go index bd6985a3..956b2019 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -51,6 +51,7 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) { integer("gateway.inbound_debounce_ms", &c.Gateway.InboundDebounceMs) boolean("gateway.block_reply", &c.Gateway.BlockReply) boolean("gateway.tool_status", &c.Gateway.ToolStatus) + boolean("gateway.team_work_classify", &c.Gateway.TeamWorkClassify) integer("gateway.task_recovery_interval_sec", &c.Gateway.TaskRecoveryIntervalSec) integer("gateway.webhook_async_timeout_sec", &c.Gateway.WebhookAsyncTimeoutSec) integer("gateway.webhook_sync_timeout_sec", &c.Gateway.WebhookSyncTimeoutSec) diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index ff8572a0..681fd6e3 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -16,9 +16,11 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/gateway" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/memory" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sessions" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/teamworkclassify" "github.com/nextlevelbuilder/goclaw/internal/tools" usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -26,15 +28,19 @@ import ( // ChatMethods handles chat.send, chat.history, chat.abort, chat.inject. type ChatMethods struct { - agents *agent.Router - sessions store.SessionStore - cfg *config.Config - rateLimiter *gateway.RateLimiter - eventBus bus.EventPublisher - postTurn tools.PostTurnProcessor - audioMgr *audio.Manager // for TTS auto-apply on WS responses (nil = disabled) - usageCaps *usagecaps.Service - debouncer *chatDebouncer + agents *agent.Router + sessions store.SessionStore + cfg *config.Config + rateLimiter *gateway.RateLimiter + eventBus bus.EventPublisher + postTurn tools.PostTurnProcessor + audioMgr *audio.Manager // for TTS auto-apply on WS responses (nil = disabled) + usageCaps *usagecaps.Service + debouncer *chatDebouncer + agentStore store.AgentStore + teamStore store.TeamStore + linkStore store.AgentLinkStore + teamWorkEmbedder memory.EmbeddingProvider } func NewChatMethods(agents *agent.Router, sess store.SessionStore, cfg *config.Config, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { @@ -52,6 +58,13 @@ func (m *ChatMethods) SetUsageCapService(s *usagecaps.Service) { m.usageCaps = s } +func (m *ChatMethods) SetTeamWorkClassification(agentStore store.AgentStore, teamStore store.TeamStore, linkStore store.AgentLinkStore, embedder memory.EmbeddingProvider) { + m.agentStore = agentStore + m.teamStore = teamStore + m.linkStore = linkStore + m.teamWorkEmbedder = embedder +} + // SetPostTurnProcessor sets the post-turn processor for team task dispatch. func (m *ChatMethods) SetPostTurnProcessor(pt tools.PostTurnProcessor) { m.postTurn = pt @@ -247,6 +260,54 @@ func (m *ChatMethods) abortChatSession(reqID string, client *gateway.Client, ses })) } +type chatTeamWorkGateOutcome struct { + message string + directive *agent.TeamWorkDirective +} + +func (m *ChatMethods) applyTeamWorkGate(ctx context.Context, params chatSendParams, loop agent.Agent, sessionKey string) chatTeamWorkGateOutcome { + out := chatTeamWorkGateOutcome{message: params.Message} + if m.cfg == nil || m.cfg.Gateway.TeamWorkClassify == nil || !*m.cfg.Gateway.TeamWorkClassify { + return out + } + if m.teamWorkEmbedder == nil || m.agentStore == nil { + slog.Info("team_work_classify: ws skipped; embedding or agent store unavailable", "session", sessionKey, "agent", params.AgentID) + return out + } + agentUUID := loop.UUID() + if agentUUID == uuid.Nil { + return out + } + mode := agent.ResolveOrchestrationMode(ctx, agentUUID, m.teamStore, m.linkStore) + if mode == agent.ModeSpawn { + slog.Info("team_work_classify: ws skipped; no team/delegate capability", "session", sessionKey, "agent", params.AgentID) + return out + } + input := teamworkclassify.BuildInputFromStores(ctx, teamworkclassify.ProfileStores{ + Agents: m.agentStore, + Teams: m.teamStore, + AgentLinks: m.linkStore, + }, teamworkclassify.BuildInputOptions{ + Mode: teamworkclassify.Mode(mode), + Message: params.Message, + AgentID: agentUUID, + Embedder: m.teamWorkEmbedder, + }) + result := teamworkclassify.ClassifyWithLLM(ctx, input, loop.Provider(), loop.Model(), m.usageCaps) + slog.Info("team_work_classify: ws decision", "session", sessionKey, "agent", params.AgentID, "mode", mode, "decision", result.Decision, "self_score", result.SelfScore, "collaboration_score", result.CollaborationScore, "reason", result.Reason) + if result.Decision == teamworkclassify.DecisionTeam { + out.directive = &agent.TeamWorkDirective{ + Mode: string(result.Mode), + Source: "llm", + Reason: result.Reason, + OriginalMessage: params.Message, + RequiredTool: result.RequiredTool, + WorkflowHint: result.WorkflowHint, + } + } + return out +} + func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) { if len(requests) == 0 { return @@ -279,6 +340,8 @@ func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) { if userID != "" { runCtxBase = store.WithUserID(runCtxBase, userID) } + gate := m.applyTeamWorkGate(runCtxBase, params, loop, sessionKey) + params.Message = gate.message // Inject team dispatch tracker: gates team_tasks create (must search/list first) // and defers task dispatch to post-turn. runCtxBase, drainTeamDispatch := tools.InjectTeamDispatch(runCtxBase, m.postTurn) @@ -324,16 +387,17 @@ func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) { } result, err := loop.Run(runCtx, agent.RunRequest{ - SessionKey: sessionKey, - Message: message, - Media: mediaFiles, - Channel: "ws", - ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID()) - WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow - RunID: runID, - UserID: userID, - Stream: params.Stream, - InjectCh: injectCh, + SessionKey: sessionKey, + Message: message, + Media: mediaFiles, + Channel: "ws", + ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID()) + WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow + RunID: runID, + UserID: userID, + Stream: params.Stream, + TeamWorkDirective: gate.directive, + InjectCh: injectCh, // Wire trace ID back to the active run so force-abort can mark the // correct trace as cancelled if the goroutine does not exit within 3s. OnTraceCreated: func(traceID uuid.UUID) { diff --git a/internal/teamworkclassify/classifier.go b/internal/teamworkclassify/classifier.go new file mode 100644 index 00000000..187ad3d2 --- /dev/null +++ b/internal/teamworkclassify/classifier.go @@ -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 +} diff --git a/internal/teamworkclassify/classifier_test.go b/internal/teamworkclassify/classifier_test.go new file mode 100644 index 00000000..f1f68e28 --- /dev/null +++ b/internal/teamworkclassify/classifier_test.go @@ -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) + } + } +} diff --git a/internal/teamworkclassify/context.go b/internal/teamworkclassify/context.go new file mode 100644 index 00000000..1d922e9a --- /dev/null +++ b/internal/teamworkclassify/context.go @@ -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 "" +} diff --git a/ui/web/src/components/layout/system-settings-constants.ts b/ui/web/src/components/layout/system-settings-constants.ts index 07d26383..5f9a7833 100644 --- a/ui/web/src/components/layout/system-settings-constants.ts +++ b/ui/web/src/components/layout/system-settings-constants.ts @@ -32,6 +32,7 @@ export interface InitState { embMaxChunkLen: string; embChunkOverlap: string; intentClassify: boolean; + teamWorkClassify: boolean; compProvider: string; compModel: string; compThreshold: string; @@ -53,6 +54,7 @@ export const DEFAULTS: InitState = { embProvider: "", embModel: "", embMaxChunkLen: "", embChunkOverlap: "", intentClassify: true, + teamWorkClassify: false, compProvider: "", compModel: "", compThreshold: "", compKeepRecent: "", compMaxTokens: "", kgProvider: "", kgModel: "", kgMinConfidence: "0.75", diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index 45149938..7270d112 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog, Brain } from "lucide-react"; +import { Settings2, Loader2, Save, AlertTriangle, Info, ExternalLink, Network, Cog, Brain, UsersRound } from "lucide-react"; import { Link } from "react-router"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -42,6 +42,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP // UX Behavior const [intentClassify, setIntentClassify] = useState(true); + const [teamWorkClassify, setTeamWorkClassify] = useState(false); // Compaction const [compProvider, setCompProvider] = useState(""); @@ -72,6 +73,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "", embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "", intentClassify: parseBool(configs["gateway.intent_classify"], true), + teamWorkClassify: parseBool(configs["gateway.team_work_classify"], false), compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "", compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "", compMaxTokens: configs["compaction.max_tokens"] ?? "", @@ -87,6 +89,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP setInit(s); setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap); setIntentClassify(s.intentClassify); + setTeamWorkClassify(s.teamWorkClassify); setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens); setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence); setBgProvider(s.bgProvider); setBgModel(s.bgModel); @@ -129,6 +132,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP if (embMaxChunkLen !== init.embMaxChunkLen) updates["embedding.max_chunk_len"] = embMaxChunkLen; if (embChunkOverlap !== init.embChunkOverlap) updates["embedding.chunk_overlap"] = embChunkOverlap; if (intentClassify !== init.intentClassify) updates["gateway.intent_classify"] = String(intentClassify); + if (teamWorkClassify !== init.teamWorkClassify) updates["gateway.team_work_classify"] = String(teamWorkClassify); if (compProvider !== init.compProvider) updates["compaction.provider"] = compProvider; if (compModel !== init.compModel) updates["compaction.model"] = compModel; if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold; @@ -155,6 +159,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP const uxItems: FeatureSwitchItem[] = [ { icon: Brain, iconClass: "text-orange-500", label: t("ux.intentClassify"), hint: t("ux.intentClassifyHint"), checked: intentClassify, onCheckedChange: setIntentClassify, infoWhenOn: t("ux.intentClassifyInfo"), infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300" }, + { icon: UsersRound, iconClass: "text-blue-500", label: t("ux.teamWorkClassify"), hint: t("ux.teamWorkClassifyHint"), checked: teamWorkClassify && !!embProvider, onCheckedChange: setTeamWorkClassify, disabled: !embProvider, disabledHint: t("ux.teamWorkClassifyEmbeddingRequired"), infoWhenOn: t("ux.teamWorkClassifyInfo"), infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300" }, ]; return ( diff --git a/ui/web/src/components/shared/feature-switch-group.tsx b/ui/web/src/components/shared/feature-switch-group.tsx index 5d2cb816..44200b19 100644 --- a/ui/web/src/components/shared/feature-switch-group.tsx +++ b/ui/web/src/components/shared/feature-switch-group.tsx @@ -23,6 +23,8 @@ export interface FeatureSwitchItem { tooltip?: string; checked: boolean; onCheckedChange: (v: boolean) => void; + disabled?: boolean; + disabledHint?: string; /** Contextual info message shown when the toggle is ON */ infoWhenOn?: string; /** Accent classes for the info box (border + bg + text + dark variants) */ @@ -80,12 +82,20 @@ export function FeatureSwitchGroup({ + {item.disabled && item.disabledHint && ( +
+ + {item.disabledHint} +
+ )} + {/* Conditional info box when enabled */} - {item.checked && item.infoWhenOn && ( + {item.checked && !item.disabled && item.infoWhenOn && (
{ setUx({ intent_classify: ag.intent_classify !== false, + team_work_classify: gw.team_work_classify === true, }); setRate({ max_message_chars: gw.max_message_chars, @@ -89,6 +91,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) { inbound_debounce_ms: rate.inbound_debounce_ms, injection_action: security.injection_action, chat_behavior: chatBehavior, + team_work_classify: ux.team_work_classify, }, agents: { defaults: { intent_classify: ux.intent_classify }, diff --git a/ui/web/src/pages/config/sections/behavior-ux-card.tsx b/ui/web/src/pages/config/sections/behavior-ux-card.tsx index 23d1cfb6..4bd3700f 100644 --- a/ui/web/src/pages/config/sections/behavior-ux-card.tsx +++ b/ui/web/src/pages/config/sections/behavior-ux-card.tsx @@ -1,10 +1,12 @@ -import { Brain } from "lucide-react"; +import { Brain, UsersRound } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group"; import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group"; +import { useEmbeddingStatus } from "@/hooks/use-embedding-status"; interface UxValues { intent_classify: boolean; + team_work_classify: boolean; } interface Props { @@ -15,6 +17,8 @@ interface Props { /** High-impact UX toggles with icon, hint, and contextual info. */ export function BehaviorUxCard({ value, onChange }: Props) { const { t } = useTranslation("config"); + const { status } = useEmbeddingStatus(); + const embeddingConfigured = status?.configured === true; const items: FeatureSwitchItem[] = [ { @@ -27,6 +31,18 @@ export function BehaviorUxCard({ value, onChange }: Props) { infoWhenOn: t("behavior.intentClassifyInfo"), infoClass: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300", }, + { + icon: UsersRound, + iconClass: "text-blue-500", + label: t("behavior.teamWorkClassify"), + hint: t("behavior.teamWorkClassifyHint"), + checked: value.team_work_classify === true && embeddingConfigured, + onCheckedChange: (v) => onChange({ ...value, team_work_classify: v }), + disabled: !embeddingConfigured, + disabledHint: t("behavior.teamWorkClassifyEmbeddingRequired"), + infoWhenOn: t("behavior.teamWorkClassifyInfo"), + infoClass: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300", + }, ]; return (