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({