feat: add team work classification routing (#1379)

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

No files matched your search

+3 -2
View File
@@ -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 {
+4 -1
View File
@@ -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,
}
+3
View File
@@ -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
}
+8 -2
View File
@@ -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 {
+2
View File
@@ -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
}
+1 -1
View File
@@ -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).
+3 -1
View File
@@ -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)
+5 -1
View File
@@ -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.
+1
View File
@@ -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
+66
View File
@@ -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
}
+65
View File
@@ -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")
}
}
+29 -3
View File
@@ -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
+1
View File
@@ -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")
+100
View File
@@ -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)
}
@@ -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)
}
}
+1
View File
@@ -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.
+1
View File
@@ -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)
+83 -19
View File
@@ -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) {
+546
View File
@@ -0,0 +1,546 @@
package teamworkclassify
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
type Decision string
const (
DecisionSelf Decision = "self"
DecisionTeam Decision = "team"
)
type Mode string
const (
ModeSpawn Mode = "spawn"
ModeDelegate Mode = "delegate"
ModeTeam Mode = "team"
)
const (
DefaultCloseMargin = 0.08
defaultTeamThreshold = 0.35
defaultEvidenceTimeout = 8 * time.Second
defaultArbiterTimeout = 30 * time.Second
)
type Embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
}
type Profile struct {
Kind string
Name string
Text string
}
type Input struct {
Mode Mode
Message string
CurrentAgent Profile
SelfTools []Profile
Team Profile
Members []Profile
Delegates []Profile
CollaborationTools []Profile
TeamRole string
CanAssignTeamTasks bool
MemberRequestsEnabled bool
MemberRequestsAutoDispatch bool
Embedder Embedder
CloseMargin float64
TeamThreshold float64
Timeout time.Duration
}
type Result struct {
Decision Decision
Confidence float64
Reason string
SelfScore float64
CollaborationScore float64
Mode Mode
RequiredTool string
WorkflowHint string
}
func Classify(ctx context.Context, input Input) Result {
if input.Mode == "" || input.Mode == ModeSpawn {
return Result{Decision: DecisionSelf, Reason: "no team or delegate capability"}
}
if input.Embedder == nil {
return Result{Decision: DecisionSelf, Reason: "embedding unavailable"}
}
if strings.TrimSpace(input.Message) == "" {
return Result{Decision: DecisionSelf, Reason: "empty message"}
}
if looksCasualOrSmallDirect(input.Message) {
return Result{Decision: DecisionSelf, Reason: "message looks casual or direct"}
}
timeout := input.Timeout
if timeout <= 0 {
timeout = defaultEvidenceTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
selfDocs, collaborationDocs := splitProfileDocuments(input)
if len(selfDocs) == 0 || len(collaborationDocs) == 0 {
return Result{Decision: DecisionSelf, Reason: "insufficient collaboration profile"}
}
texts := append([]string{input.Message}, append(selfDocs, collaborationDocs...)...)
vectors, err := input.Embedder.Embed(ctx, texts)
if err != nil || len(vectors) != len(texts) {
return Result{Decision: DecisionSelf, Reason: "embedding failed"}
}
query := vectors[0]
selfScore := bestCosine(query, vectors[1:1+len(selfDocs)])
collabScore := bestCosine(query, vectors[1+len(selfDocs):])
margin := input.CloseMargin
if margin <= 0 {
margin = DefaultCloseMargin
}
threshold := input.TeamThreshold
if threshold <= 0 {
threshold = defaultTeamThreshold
}
diff := collabScore - selfScore
bestScore := math.Max(selfScore, collabScore)
switch {
case math.Abs(diff) <= margin:
return Result{
Decision: DecisionSelf,
Confidence: bestScore,
Reason: "profiles are close; defaulting to self",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
}
case diff > margin && collabScore >= threshold:
return Result{
Decision: DecisionTeam,
Confidence: diff,
Reason: "request is closer to team/delegate capability",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
RequiredTool: requiredToolForMode(input.Mode),
}
default:
return Result{
Decision: DecisionSelf,
Confidence: -diff,
Reason: "request is closer to current agent capability",
SelfScore: selfScore,
CollaborationScore: collabScore,
Mode: input.Mode,
}
}
}
func ClassifyWithLLM(ctx context.Context, input Input, provider providers.Provider, model string, caps *usagecaps.Service) Result {
fallback := Classify(ctx, input)
if input.Mode == "" || input.Mode == ModeSpawn || provider == nil || strings.TrimSpace(model) == "" {
return forceSelfDecision(fallback, "arbiter unavailable: ")
}
timeout := input.Timeout
if timeout <= 0 {
timeout = defaultArbiterTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req := providers.ChatRequest{
Messages: BuildArbiterMessages(input, fallback),
Model: model,
Options: map[string]any{
providers.OptMaxTokens: 300,
providers.OptTemperature: 0.0,
},
}
var (
resp *providers.ChatResponse
err error
)
if caps != nil {
resp, err = caps.Chat(ctx, provider, req, usagecaps.ChatOptions{
ModelID: model,
Purpose: "team-work-classify",
MaxOutputTokens: 300,
})
} else {
resp, err = provider.Chat(ctx, req)
}
if err != nil || resp == nil {
slog.Warn("team_work_classify: arbiter call failed",
"model", model,
"provider_type", fmt.Sprintf("%T", provider),
"has_usage_caps", caps != nil,
"mode", input.Mode,
"timeout", timeout.String(),
"response_nil", resp == nil,
"error", err,
)
return forceSelfDecision(fallback, "arbiter_failed: ")
}
result, err := ParseArbiterResult(resp.Content, input.Mode)
if err != nil {
slog.Warn("team_work_classify: arbiter parse failed",
"model", model,
"provider_type", fmt.Sprintf("%T", provider),
"mode", input.Mode,
"content_len", len(resp.Content),
"error", err,
)
return forceSelfDecision(fallback, "arbiter_parse_failed: ")
}
result.SelfScore = fallback.SelfScore
result.CollaborationScore = fallback.CollaborationScore
if result.Reason == "" {
result.Reason = fallback.Reason
}
return applyTeamPermissionGate(input, result)
}
func forceSelfDecision(evidence Result, reasonPrefix string) Result {
evidence.Decision = DecisionSelf
evidence.RequiredTool = ""
evidence.WorkflowHint = ""
evidence.Reason = reasonPrefix + evidence.Reason
return evidence
}
func applyTeamPermissionGate(input Input, result Result) Result {
if result.Decision != DecisionTeam {
return result
}
if result.WorkflowHint == "" {
result.WorkflowHint = workflowHintForInput(input)
}
if input.Mode != ModeTeam {
return result
}
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role == "" || role == "lead" || input.CanAssignTeamTasks {
return result
}
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return result
}
result.Decision = DecisionSelf
result.RequiredTool = ""
result.WorkflowHint = ""
reason := "member lacks auto-dispatch team request permission"
if !input.MemberRequestsEnabled {
reason = "member lacks team request permission"
}
if strings.TrimSpace(result.Reason) == "" {
result.Reason = reason
} else {
result.Reason = reason + ": " + result.Reason
}
return result
}
func workflowHintForInput(input Input) string {
switch input.Mode {
case ModeDelegate:
return "Use the `delegate` tool with an available linked agent. Do not invent agent keys."
case ModeTeam:
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role != "" && role != "lead" && !input.CanAssignTeamTasks {
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return `As a team member, do not create or assign general team tasks. Use team_tasks(action="create", task_type="request", ...) to ask a teammate for help. This team's member requests auto-dispatch to the assignee after creation.`
}
if input.MemberRequestsEnabled {
return "As a team member, member requests are enabled but auto-dispatch is disabled; choose self because request tasks would stay pending for leader review and may not run in this turn."
}
return "As a team member, you cannot create or assign general team tasks, and member request tasks are disabled. Handle the request directly or explain that a lead must coordinate the team work."
}
return `Use team_tasks(action="search" or "list") first, then team_tasks(action="create", ...) to assign work to an appropriate team member when new team work is required.`
default:
return ""
}
}
func BuildArbiterMessages(input Input, evidence Result) []providers.Message {
system := `You are a Team Work routing arbiter.
Return ONLY JSON. Do not answer the user.
Choose exactly one decision:
- self: the current agent should handle the request directly.
- team: the current agent must use the available team/delegate workflow.
Never return ask. If uncertain, choose self so normal chat is not disrupted.
Strict routing policy:
- Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files, documents, results, or prior team outputs.
- Choose self when the current agent can answer by using existing files, existing task results, or already completed team work without assigning new work.
- Do not choose team only because files are located in the team workspace.
- Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output.
- Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work.
- Choose team when the request clearly needs new work from multiple roles, not merely synthesis of existing material.
- Permission matters: a team member who is not lead cannot assign or create general team tasks.
- If the current agent is a member and member requests are disabled, choose self for requests to assign, split, coordinate, or ask teammates.
- If the current agent is a member and member requests are enabled but auto-dispatch is disabled, choose self because pending leader review is not an immediate executable workflow.
- If the current agent is a member and member requests plus auto-dispatch are enabled, choose team only for a request-help workflow using task_type="request"; do not treat it as lead-style assignment.
- When self_score and collaboration_score are close, choose self unless there is a clear team signal.`
var b strings.Builder
b.WriteString("User request:\n")
b.WriteString(input.Message)
b.WriteString("\n\nRouting mode: ")
b.WriteString(string(input.Mode))
b.WriteString("\nRequired workflow tool when team is chosen: ")
b.WriteString(requiredToolForMode(input.Mode))
if input.Mode == ModeTeam {
b.WriteString("\n\nTeam permission context:\n")
b.WriteString("current_agent_team_role: ")
b.WriteString(firstNonEmpty(input.TeamRole, "unknown"))
b.WriteString("\ncan_assign_team_tasks: ")
b.WriteString(fmt.Sprintf("%t", input.CanAssignTeamTasks))
b.WriteString("\nmember_requests_enabled: ")
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsEnabled))
b.WriteString("\nmember_requests_auto_dispatch: ")
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsAutoDispatch))
if hint := workflowHintForInput(input); hint != "" {
b.WriteString("\nworkflow_hint: ")
b.WriteString(hint)
}
}
b.WriteString("\n\nEmbedding evidence:\n")
b.WriteString(fmt.Sprintf("self_score: %.4f\n", evidence.SelfScore))
b.WriteString(fmt.Sprintf("collaboration_score: %.4f\n", evidence.CollaborationScore))
b.WriteString("embedding_fallback_decision: ")
b.WriteString(string(evidence.Decision))
b.WriteString("\n\nCurrent agent and direct capability:\n")
for _, doc := range appendProfileDocs(input.CurrentAgent, input.SelfTools) {
b.WriteString("---\n")
b.WriteString(doc)
b.WriteString("\n")
}
b.WriteString("\nTeam/delegate/tool capability:\n")
collaborationProfiles := append([]Profile{}, input.Members...)
collaborationProfiles = append(collaborationProfiles, input.Delegates...)
collaborationProfiles = append(collaborationProfiles, input.CollaborationTools...)
for _, doc := range appendProfileDocs(input.Team, collaborationProfiles) {
b.WriteString("---\n")
b.WriteString(doc)
b.WriteString("\n")
}
b.WriteString("\nReturn JSON shape:\n")
b.WriteString(`{"decision":"self|team","confidence":0.0,"mode":"team|delegate","required_tool":"team_tasks|delegate","workflow_hint":"short permission-aware tool guidance","reason":"short internal reason"}`)
return []providers.Message{
{Role: "system", Content: system},
{Role: "user", Content: b.String()},
}
}
func appendProfileDocs(first Profile, rest []Profile) []string {
var docs []string
if doc := renderProfile(first); doc != "" {
docs = append(docs, doc)
}
for _, p := range rest {
if doc := renderProfile(p); doc != "" {
docs = append(docs, doc)
}
}
return docs
}
func ParseArbiterResult(content string, mode Mode) (Result, error) {
raw := strings.TrimSpace(content)
if start := strings.Index(raw, "{"); start >= 0 {
if end := strings.LastIndex(raw, "}"); end >= start {
raw = raw[start : end+1]
}
}
var parsed struct {
Decision string `json:"decision"`
Confidence float64 `json:"confidence"`
Mode string `json:"mode"`
RequiredTool string `json:"required_tool"`
WorkflowHint string `json:"workflow_hint"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return Result{}, err
}
resultMode := Mode(strings.TrimSpace(parsed.Mode))
if resultMode == "" || resultMode == ModeSpawn {
resultMode = mode
}
requiredTool := strings.TrimSpace(parsed.RequiredTool)
if requiredTool == "" {
requiredTool = requiredToolForMode(resultMode)
}
switch Decision(strings.ToLower(strings.TrimSpace(parsed.Decision))) {
case DecisionTeam:
if resultMode != ModeTeam && resultMode != ModeDelegate {
resultMode = mode
}
if resultMode != ModeTeam && resultMode != ModeDelegate {
return Result{Decision: DecisionSelf, Mode: mode, Reason: "arbiter requested team without workflow mode"}, nil
}
return Result{
Decision: DecisionTeam,
Confidence: parsed.Confidence,
Reason: strings.TrimSpace(parsed.Reason),
Mode: resultMode,
RequiredTool: requiredTool,
WorkflowHint: strings.TrimSpace(parsed.WorkflowHint),
}, nil
case DecisionSelf:
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: strings.TrimSpace(parsed.Reason), Mode: mode}, nil
default:
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: "arbiter returned unsupported decision; defaulting to self", Mode: mode}, nil
}
}
func requiredToolForMode(mode Mode) string {
switch mode {
case ModeTeam:
return "team_tasks"
case ModeDelegate:
return "delegate"
default:
return ""
}
}
func looksCasualOrSmallDirect(message string) bool {
s := strings.ToLower(strings.TrimSpace(message))
if s == "" {
return true
}
actionMarkers := []string{
"hãy ", "hay ", "viết", "viet", "tạo", "tao", "làm", "lam",
"kiểm tra", "kiem tra", "phân tích", "phan tich", "soạn", "soan",
"dịch", "dich", "tìm", "tim", "lập kế hoạch", "lap ke hoach",
"triển khai", "trien khai", "thiết kế", "thiet ke", "sửa", "sua",
"đánh giá", "danh gia", "tóm tắt", "tom tat", "nghiên cứu", "nghien cuu",
"check", "create", "write", "analyze", "analyse", "fix", "build",
"plan", "research", "design", "review", "summarize", "summarise",
"查", "写", "创建", "分析", "修复", "设计", "总结",
"작성", "생성", "분석", "수정", "설계", "요약",
}
for _, marker := range actionMarkers {
if strings.Contains(s, marker) {
return false
}
}
casualMarkers := []string{
"chào", "chao", "hello", "hi", "ok", "ừ", "uh", "cảm ơn", "cam on",
"thanks", "thank you", "xin lỗi", "sorry", "được rồi", "duoc roi",
}
for _, marker := range casualMarkers {
if strings.Contains(s, marker) {
return true
}
}
return len([]rune(s)) <= 80
}
func BuildProfileDocuments(input Input) []string {
selfDocs, collaborationDocs := splitProfileDocuments(input)
return append(selfDocs, collaborationDocs...)
}
func splitProfileDocuments(input Input) ([]string, []string) {
var selfDocs []string
if doc := renderProfile(input.CurrentAgent); doc != "" {
selfDocs = append(selfDocs, doc)
}
for _, p := range input.SelfTools {
if doc := renderProfile(p); doc != "" {
selfDocs = append(selfDocs, doc)
}
}
var collaborationDocs []string
if doc := renderProfile(input.Team); doc != "" {
collaborationDocs = append(collaborationDocs, doc)
}
for _, group := range [][]Profile{input.Members, input.Delegates, input.CollaborationTools} {
for _, p := range group {
if doc := renderProfile(p); doc != "" {
collaborationDocs = append(collaborationDocs, doc)
}
}
}
return selfDocs, collaborationDocs
}
func renderProfile(p Profile) string {
name := strings.TrimSpace(p.Name)
text := strings.TrimSpace(p.Text)
kind := strings.TrimSpace(p.Kind)
if name == "" && text == "" {
return ""
}
var b strings.Builder
if kind != "" {
b.WriteString("kind: ")
b.WriteString(kind)
b.WriteString("\n")
}
if name != "" {
b.WriteString("name: ")
b.WriteString(name)
b.WriteString("\n")
}
if text != "" {
b.WriteString("description: ")
b.WriteString(text)
}
return b.String()
}
func bestCosine(query []float32, docs [][]float32) float64 {
best := -1.0
for _, doc := range docs {
score, err := cosine(query, doc)
if err == nil && score > best {
best = score
}
}
if best < 0 {
return 0
}
return best
}
func cosine(a, b []float32) (float64, error) {
if len(a) == 0 || len(a) != len(b) {
return 0, errors.New("dimension mismatch")
}
var dot, na, nb float64
for i := range a {
av := float64(a[i])
bv := float64(b[i])
dot += av * bv
na += av * av
nb += bv * bv
}
if na == 0 || nb == 0 {
return 0, nil
}
return dot / (math.Sqrt(na) * math.Sqrt(nb)), nil
}
@@ -0,0 +1,434 @@
package teamworkclassify
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type fakeEmbedder map[string][]float32
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, 0, len(texts))
for _, text := range texts {
var vec []float32
for key, v := range f {
if strings.Contains(text, key) {
vec = v
break
}
}
if vec == nil {
vec = []float32{0, 0}
}
out = append(out, vec)
}
return out, nil
}
func TestClassifySpawnModeSkipsToSelf(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeSpawn,
Message: "lập kế hoạch content và phân tích chiến lược",
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
}
if result.Reason == "" {
t.Fatal("Reason is empty")
}
}
func TestClassifyTeamWhenRequestMatchesTeamMemberOrToolProfile(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và phân tích chiến lược cho chiến dịch mới",
CurrentAgent: Profile{
Kind: "agent",
Name: "Bảo An",
Text: "điều phối chung",
},
SelfTools: []Profile{
{Kind: "tool", Name: "self chat", Text: "trả lời trò chuyện nhanh"},
},
Team: Profile{
Kind: "team",
Name: "Growth Team",
Text: "team content chiến lược chiến dịch",
},
Members: []Profile{
{Kind: "member", Name: "Bảo Ly Content", Text: "content bài viết kịch bản truyền thông"},
{Kind: "member", Name: "Bảo Ly Chiến lược", Text: "chiến lược kế hoạch phân tích"},
},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "chia việc giao task cho thành viên team"},
},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"chiến lược": {1, 0},
"điều phối": {0, 1},
"trò chuyện": {0, 1},
},
})
if result.Decision != DecisionTeam {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionTeam, result)
}
if result.CollaborationScore <= result.SelfScore {
t.Fatalf("CollaborationScore %.3f must be greater than SelfScore %.3f", result.CollaborationScore, result.SelfScore)
}
}
func TestClassifySelfWhenCurrentAgentToolsAreBestMatch(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeDelegate,
Message: "dịch nhanh câu này sang tiếng Anh",
CurrentAgent: Profile{
Kind: "agent",
Name: "Translator",
Text: "dịch thuật trả lời ngắn",
},
SelfTools: []Profile{
{Kind: "tool", Name: "translate", Text: "dịch nhanh văn bản"},
},
Delegates: []Profile{
{Kind: "delegate", Name: "Planner", Text: "lập kế hoạch dự án dài"},
},
Embedder: fakeEmbedder{
"dịch nhanh": {1, 0},
"dịch thuật": {1, 0},
"lập kế hoạch": {0, 1},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
}
func TestClassifyDefaultsToSelfWhenScoresAreClose(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: "phân tích giúp việc này nên xử lý theo cá nhân hay theo team",
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
Embedder: fakeEmbedder{
"phân tích": {1, 0},
"xử lý yêu cầu chung": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; scores %.3f %.3f", result.Decision, DecisionSelf, result.SelfScore, result.CollaborationScore)
}
}
func TestClassifySelfForCasualOrWeakCloseMessages(t *testing.T) {
cases := []struct {
name string
message string
}{
{name: "casual greeting", message: "chào em"},
{name: "weak close scores", message: "anh hỏi thêm chút thôi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := Classify(context.Background(), Input{
Mode: ModeTeam,
Message: tc.message,
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
Embedder: fakeEmbedder{
"xử lý yêu cầu chung": {1, 0},
},
})
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
})
}
}
func TestClassifyWithLLMUsesLongerDefaultArbiterTimeout(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"self","confidence":0.5,"mode":"team","reason":"direct enough"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
}
if provider.deadlineRemaining < 25*time.Second {
t.Fatalf("arbiter deadline remaining = %s, want at least 25s", provider.deadlineRemaining)
}
}
func TestClassifyWithLLMFallsBackToSelfOnArbiterError(t *testing.T) {
provider := &fakeArbiterProvider{err: errors.New("provider unavailable")}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
}
if !strings.Contains(result.Reason, "arbiter_failed") {
t.Fatalf("Reason = %q, want arbiter_failed marker", result.Reason)
}
}
type fakeArbiterProvider struct {
content string
err error
req providers.ChatRequest
deadlineRemaining time.Duration
}
func (p *fakeArbiterProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
p.req = req
if deadline, ok := ctx.Deadline(); ok {
p.deadlineRemaining = time.Until(deadline)
}
if p.err != nil {
return nil, p.err
}
return &providers.ChatResponse{Content: p.content}, nil
}
func (p *fakeArbiterProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
return nil, nil
}
func (p *fakeArbiterProvider) DefaultModel() string { return "fake-model" }
func (p *fakeArbiterProvider) Name() string { return "fake-provider" }
func TestParseArbiterResultAcceptsSelfOrTeamOnly(t *testing.T) {
team, err := ParseArbiterResult(`{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"needs members"}`, ModeTeam)
if err != nil {
t.Fatalf("ParseArbiterResult(team) error = %v", err)
}
if team.Decision != DecisionTeam || team.RequiredTool != "team_tasks" || team.Mode != ModeTeam {
t.Fatalf("team result = %+v", team)
}
self, err := ParseArbiterResult(`{"decision":"ask","confidence":0.55,"mode":"team","reason":"unclear"}`, ModeTeam)
if err != nil {
t.Fatalf("ParseArbiterResult(unsupported decision fallback) error = %v", err)
}
if self.Decision != DecisionSelf {
t.Fatalf("unsupported decision must fall back to self, got %+v", self)
}
}
func TestClassifyWithLLMUsesArbiterDecisionAndEvidence(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.86,"mode":"team","required_tool":"team_tasks","reason":"requires content and strategy members"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
Members: []Profile{
{Kind: "team_member", Name: "Bảo Ly Content", Text: "content"},
{Kind: "team_member", Name: "Bảo Ly Chiến lược", Text: "chiến lược"},
},
CollaborationTools: []Profile{{Kind: "tool", Name: "team_tasks", Text: "assign team work"}},
Embedder: fakeEmbedder{
"lập kế hoạch": {1, 0},
"content": {1, 0},
"chiến lược": {1, 0},
"điều phối": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
t.Fatalf("ClassifyWithLLM result = %+v", result)
}
if len(provider.req.Messages) < 2 {
t.Fatalf("arbiter request messages missing: %+v", provider.req)
}
joined := provider.req.Messages[0].Content + "\n" + provider.req.Messages[1].Content
for _, want := range []string{"Return ONLY JSON", "Bảo An", "Growth Team", "team_tasks", "self_score", "collaboration_score"} {
if !strings.Contains(joined, want) {
t.Fatalf("arbiter prompt missing %q:\n%s", want, joined)
}
}
}
func TestClassifyWithLLMForcesSelfWhenMemberCannotAssignOrRequest(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"route to teammate"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "nhờ thành viên khác làm giúp phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "shared work"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: false,
Embedder: fakeEmbedder{
"nhờ thành viên": {1, 0},
"shared work": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want self for member without request permission; result=%+v", result.Decision, result)
}
if result.RequiredTool != "" {
t.Fatalf("RequiredTool = %q, want empty", result.RequiredTool)
}
if !strings.Contains(result.Reason, "member lacks team request permission") {
t.Fatalf("Reason = %q, want member permission marker", result.Reason)
}
}
func TestClassifyWithLLMForcesSelfWhenMemberRequestNeedsLeaderReview(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: false,
Embedder: fakeEmbedder{
"request": {1, 0},
"content team": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionSelf {
t.Fatalf("Decision = %q, want self when member request does not auto-dispatch; result=%+v", result.Decision, result)
}
if !strings.Contains(result.Reason, "member lacks auto-dispatch team request permission") {
t.Fatalf("Reason = %q, want auto-dispatch permission marker", result.Reason)
}
}
func TestClassifyWithLLMAllowsAutoDispatchMemberRequest(t *testing.T) {
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
result := ClassifyWithLLM(context.Background(), Input{
Mode: ModeTeam,
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: true,
Embedder: fakeEmbedder{
"request": {1, 0},
"content team": {1, 0},
"team member": {0, 1},
},
}, provider, "arbiter-model", nil)
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
t.Fatalf("ClassifyWithLLM result = %+v, want team/team_tasks", result)
}
if !strings.Contains(result.WorkflowHint, `task_type="request"`) {
t.Fatalf("WorkflowHint = %q, want task_type=request guidance", result.WorkflowHint)
}
if !strings.Contains(result.WorkflowHint, "auto-dispatch") {
t.Fatalf("WorkflowHint = %q, want auto-dispatch guidance", result.WorkflowHint)
}
}
func TestBuildArbiterMessagesIncludesStrictTeamRoutingPolicy(t *testing.T) {
messages := BuildArbiterMessages(Input{
Mode: ModeTeam,
Message: "em đọc cả 2 file rồi diễn giải lại cho anh về phương án em chọn",
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "lead and synthesize existing team outputs"},
Team: Profile{Kind: "team", Name: "Strategy Team", Text: "team research and execution"},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "search existing tasks, create tasks, assign work"},
{Kind: "capability", Name: "shared team workspace", Text: "files from previous team work"},
},
}, Result{
Decision: DecisionSelf,
SelfScore: 0.6341,
CollaborationScore: 0.6362,
})
if len(messages) < 2 {
t.Fatalf("messages = %+v, want system and user messages", messages)
}
prompt := messages[0].Content + "\n" + messages[1].Content
for _, want := range []string{
"Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files",
"Do not choose team only because files are located in the team workspace",
"Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output",
"Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work",
"When self_score and collaboration_score are close, choose self unless there is a clear team signal",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("arbiter prompt missing policy %q:\n%s", want, prompt)
}
}
}
func TestBuildArbiterMessagesIncludesTeamPermissionContext(t *testing.T) {
messages := BuildArbiterMessages(Input{
Mode: ModeTeam,
Message: "nhờ thành viên khác hỗ trợ phần này",
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "member role"},
Team: Profile{Kind: "team", Name: "Team", Text: "team work"},
TeamRole: "member",
CanAssignTeamTasks: false,
MemberRequestsEnabled: true,
MemberRequestsAutoDispatch: false,
}, Result{Decision: DecisionSelf})
prompt := messages[0].Content + "\n" + messages[1].Content
for _, want := range []string{
"Team permission context",
"current_agent_team_role: member",
"can_assign_team_tasks: false",
"member_requests_enabled: true",
"member_requests_auto_dispatch: false",
"choose self",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("arbiter prompt missing %q:\n%s", want, prompt)
}
}
}
func TestBuildProfileDocumentsIncludesTeamLinksAndTools(t *testing.T) {
input := Input{
Mode: ModeTeam,
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "lead"},
SelfTools: []Profile{{Kind: "tool", Name: "web_search", Text: "search"}},
Team: Profile{Kind: "team", Name: "Team A", Text: "team"},
Members: []Profile{{Kind: "member", Name: "Member A", Text: "member"}},
Delegates: []Profile{{Kind: "delegate", Name: "Delegate A", Text: "delegate"}},
CollaborationTools: []Profile{
{Kind: "tool", Name: "team_tasks", Text: "team task board"},
},
}
docs := BuildProfileDocuments(input)
joined := strings.Join(docs, "\n")
for _, want := range []string{"Lead", "web_search", "Team A", "Member A", "Delegate A", "team_tasks"} {
if !strings.Contains(joined, want) {
t.Fatalf("profile documents missing %q:\n%s", want, joined)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package teamworkclassify
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type ProfileStores struct {
Agents store.AgentStore
Teams store.TeamStore
AgentLinks store.AgentLinkStore
}
type BuildInputOptions struct {
Mode Mode
Message string
AgentID uuid.UUID
ToolAllow []string
SkillFilter []string
Embedder Embedder
ExtraSelf []Profile
ExtraCollab []Profile
}
func BuildInputFromStores(ctx context.Context, stores ProfileStores, opts BuildInputOptions) Input {
input := Input{
Mode: opts.Mode,
Message: opts.Message,
Embedder: opts.Embedder,
}
if opts.Mode == "" || opts.Mode == ModeSpawn || opts.AgentID == uuid.Nil {
return input
}
if stores.Agents != nil {
if ag, err := stores.Agents.GetByID(ctx, opts.AgentID); err == nil && ag != nil {
input.CurrentAgent = Profile{
Kind: "agent",
Name: firstNonEmpty(ag.DisplayName, ag.AgentKey),
Text: strings.TrimSpace(strings.Join([]string{
ag.Frontmatter,
ag.AgentDescription,
}, "\n")),
}
}
}
input.SelfTools = append(input.SelfTools, effectiveSelfToolProfiles(opts.ToolAllow, opts.SkillFilter)...)
input.SelfTools = append(input.SelfTools, opts.ExtraSelf...)
if opts.Mode == ModeTeam && stores.Teams != nil {
if team, err := stores.Teams.GetTeamForAgent(ctx, opts.AgentID); err == nil && team != nil {
input.TeamRole = "member"
if team.LeadAgentID == opts.AgentID {
input.TeamRole = "lead"
input.CanAssignTeamTasks = true
}
memberRequestCfg := parseMemberRequestRoutingConfig(team.Settings)
input.MemberRequestsEnabled = memberRequestCfg.Enabled
input.MemberRequestsAutoDispatch = memberRequestCfg.AutoDispatch
input.Team = Profile{
Kind: "team",
Name: team.Name,
Text: strings.TrimSpace(strings.Join([]string{
team.Description,
fmt.Sprintf("lead_agent: %s %s", team.LeadAgentKey, team.LeadDisplayName),
}, "\n")),
}
if members, err := stores.Teams.ListMembers(ctx, team.ID); err == nil {
for _, member := range members {
if member.AgentID == opts.AgentID && !input.CanAssignTeamTasks {
if strings.TrimSpace(member.Role) != "" {
input.TeamRole = member.Role
}
}
input.Members = append(input.Members, Profile{
Kind: "team_member",
Name: firstNonEmpty(member.DisplayName, member.AgentKey),
Text: strings.TrimSpace(strings.Join([]string{
"role: " + member.Role,
"agent_key: " + member.AgentKey,
member.Frontmatter,
}, "\n")),
})
}
}
input.CollaborationTools = append(input.CollaborationTools, teamPermissionProfiles(input)...)
}
}
if stores.AgentLinks != nil {
if links, err := stores.AgentLinks.DelegateTargets(ctx, opts.AgentID); err == nil {
for _, link := range links {
input.Delegates = append(input.Delegates, Profile{
Kind: "delegate",
Name: firstNonEmpty(link.TargetDisplayName, link.TargetAgentKey),
Text: strings.TrimSpace(strings.Join([]string{
"agent_key: " + link.TargetAgentKey,
"link_direction: " + link.Direction,
"link_team: " + link.TeamName,
"target_team: " + link.TargetTeamName,
fmt.Sprintf("target_is_team_lead: %t", link.TargetIsTeamLead),
link.Description,
link.TargetDescription,
}, "\n")),
})
}
if opts.Mode == ModeDelegate && len(links) > 0 {
input.CollaborationTools = append(input.CollaborationTools,
Profile{Kind: "tool", Name: "delegate", Text: "delegate work to linked agents with matching expertise and receive their result"},
)
}
}
}
input.CollaborationTools = append(input.CollaborationTools, opts.ExtraCollab...)
return input
}
type memberRequestRoutingConfig struct {
Enabled bool
AutoDispatch bool
}
func parseMemberRequestRoutingConfig(settings json.RawMessage) memberRequestRoutingConfig {
var cfg memberRequestRoutingConfig
if len(settings) == 0 {
return cfg
}
var raw struct {
MemberRequests *struct {
Enabled *bool `json:"enabled"`
AutoDispatch *bool `json:"auto_dispatch"`
} `json:"member_requests"`
}
if json.Unmarshal(settings, &raw) != nil || raw.MemberRequests == nil {
return cfg
}
if raw.MemberRequests.Enabled != nil {
cfg.Enabled = *raw.MemberRequests.Enabled
}
if raw.MemberRequests.AutoDispatch != nil {
cfg.AutoDispatch = *raw.MemberRequests.AutoDispatch
}
return cfg
}
func teamPermissionProfiles(input Input) []Profile {
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
if role == "" || role == "lead" || input.CanAssignTeamTasks {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "lead can search existing tasks, create tasks, assign work to team members, track progress, review and complete team work"},
{Kind: "tool", Name: "ask_user", Text: "ask the user for decisions or missing information during team work"},
{Kind: "capability", Name: "shared team workspace", Text: "coordinate multi-step work through shared files, task board, and team member results"},
}
}
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: `member cannot assign general tasks; member may create task_type="request" to ask another teammate for help; requests auto-dispatch to the assignee`},
{Kind: "capability", Name: "member request workflow", Text: "request help from teammates without lead-style assignment authority"},
}
}
if input.MemberRequestsEnabled {
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "member request tasks are enabled but auto-dispatch is disabled; requests stay pending for leader review and should not be used as immediate routed workflow"},
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
}
}
return []Profile{
{Kind: "tool", Name: "team_tasks", Text: "member cannot create or assign general tasks; member request tasks are disabled; use comments/progress/current-task actions only"},
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
}
}
func effectiveSelfToolProfiles(toolAllow, skillFilter []string) []Profile {
var profiles []Profile
if len(toolAllow) > 0 {
profiles = append(profiles, Profile{
Kind: "tool_allow",
Name: "channel allowed tools",
Text: "effective channel/group allowed tools: " + strings.Join(toolAllow, ", "),
})
} else {
profiles = append(profiles, Profile{
Kind: "tool_allow",
Name: "default tools",
Text: "current agent may use its configured tools for direct small tasks",
})
}
if len(skillFilter) > 0 {
profiles = append(profiles, Profile{
Kind: "skill_filter",
Name: "topic skills",
Text: "effective topic skill filter: " + strings.Join(skillFilter, ", "),
})
}
return profiles
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
@@ -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",
@@ -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 (
@@ -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({
<Switch
checked={item.checked}
onCheckedChange={item.onCheckedChange}
disabled={item.disabled}
className="shrink-0"
/>
</div>
{item.disabled && item.disabledHint && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-muted bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{item.disabledHint}</span>
</div>
)}
{/* Conditional info box when enabled */}
{item.checked && item.infoWhenOn && (
{item.checked && !item.disabled && item.infoWhenOn && (
<div className={cn(
"mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs",
item.infoClass ?? "border-primary/20 bg-primary/5 text-primary dark:bg-primary/10",
+7 -3
View File
@@ -59,7 +59,7 @@
"agents.workspaceTip": "Root directory for agent file operations. Per-user subdirectories are created automatically.",
"agents.restrictToWorkspace": "Restrict to Workspace",
"agents.restrictToWorkspaceTip": "Prevent agents from reading or writing files outside the workspace path.",
"agents.intentClassify": "Intent Classification",
"agents.intentClassify": "Busy Agent Message Classification",
"agents.saveError": "Failed to save agent defaults",
"agents.subagents.title": "Subagents",
@@ -122,8 +122,12 @@
"behavior.uxTitle": "UX Behavior",
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
"behavior.intentClassifyHint": "Classify user intent before routing to reduce unnecessary agent invocations.",
"behavior.intentClassifyInfo": "Agent will only be invoked when the classifier detects actionable intent.",
"behavior.intentClassifyHint": "When the agent is already processing a previous turn, classify new messages as status questions, cancel requests, steering instructions, or new requests.",
"behavior.intentClassifyInfo": "This only applies while a session is busy; it does not decide whether a request needs team workflow.",
"behavior.teamWorkClassify": "Team Work Classification",
"behavior.teamWorkClassifyHint": "Classify new requests as direct handling or team/delegate workflow.",
"behavior.teamWorkClassifyInfo": "When a request exceeds the current agent's scope or should be split, the system uses embeddings as evidence and asks the model to decide between direct handling and team/delegate workflow. The system also checks lead/member permissions; members can only create help requests when the team has enabled member requests and auto-dispatch.",
"behavior.teamWorkClassifyEmbeddingRequired": "Configure embeddings before enabling Team Work Classification.",
"behavior.chatTitle": "Human-like Channel Delivery",
"behavior.chatDescription": "Sidecar acknowledgement, intermediate progress, and safe final reply splitting for non-streaming channels",
"behavior.chatEnabled": "Enable channel delivery behavior",
@@ -30,9 +30,13 @@
"ux": {
"title": "UX Behavior",
"description": "Control how the gateway displays agent activity to users.",
"intentClassify": "Intent classification",
"intentClassifyHint": "Classify user intent before routing to the agent.",
"intentClassifyInfo": "Messages are classified to optimize agent routing and behavior."
"intentClassify": "Busy agent message classification",
"intentClassifyHint": "When the agent is already processing a previous turn, classify new messages as status questions, cancel requests, steering instructions, or new requests.",
"intentClassifyInfo": "This only applies while a session is busy; it does not decide whether a request needs team workflow.",
"teamWorkClassify": "Team Work Classification",
"teamWorkClassifyHint": "Classify new requests as direct handling or team/delegate workflow.",
"teamWorkClassifyInfo": "When a request exceeds the current agent's scope or should be split, the system uses embeddings as evidence and asks the model to decide between direct handling and team/delegate workflow. The system also checks lead/member permissions; members can only create help requests when the team has enabled member requests and auto-dispatch.",
"teamWorkClassifyEmbeddingRequired": "Configure embeddings before enabling Team Work Classification."
},
"kg": {
"title": "Knowledge Graph",
+7 -3
View File
@@ -61,7 +61,7 @@
"agents.workspaceTip": "에이전트 파일 작업의 루트 디렉터리입니다. 사용자별 하위 디렉터리가 자동으로 생성됩니다.",
"agents.restrictToWorkspace": "워크스페이스로 제한",
"agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.",
"agents.intentClassify": "의도 분류",
"agents.intentClassify": "에이전트 작업 중 메시지 분류",
"agents.saveError": "에이전트 기본값 저장 실패",
"agents.subagents.title": "서브에이전트",
@@ -126,8 +126,12 @@
"behavior.toolStatusInfo": "에이전트가 파일을 읽고, 명령을 실행하고, API를 호출하는 동안 사용자에게 라이브 업데이트가 표시됩니다.",
"behavior.blockReplyHint": "도구가 호출되는 동안 채널에 부분 어시스턴트 텍스트를 전송합니다.",
"behavior.blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 전달됩니다. 최종 응답만이 아닙니다.",
"behavior.intentClassifyHint": "불필요한 에이전트 호출을 줄이기 위해 라우팅 전에 사용자 의도를 분류합니다.",
"behavior.intentClassifyInfo": "에이전트는 분류기가 실행 가능한 의도를 감지할 때만 호출됩니다.",
"behavior.intentClassifyHint": "에이전트가 이전 요청을 처리 중일 때 새 메시지를 상태 질문, 취소, 추가 지시, 새 요청으로 분류합니다.",
"behavior.intentClassifyInfo": "이 기능은 세션이 작업 중일 때만 적용되며, 요청에 팀 워크플로가 필요한지는 판단하지 않습니다.",
"behavior.teamWorkClassify": "Team Work 분류",
"behavior.teamWorkClassifyHint": "새 요청을 직접 처리 또는 team/delegate 워크플로로 분류합니다.",
"behavior.teamWorkClassifyInfo": "요청이 현재 에이전트의 범위를 넘거나 분담이 필요하면 시스템이 embedding을 근거로 사용하고 모델이 직접 처리와 team/delegate 워크플로 중 하나를 결정하게 합니다. 시스템은 lead/member 권한도 확인합니다. member는 팀에서 member request와 auto-dispatch를 모두 켠 경우에만 도움 요청을 만들 수 있습니다.",
"behavior.teamWorkClassifyEmbeddingRequired": "Team Work 분류를 켜려면 먼저 embedding을 설정해야 합니다.",
"behavior.pendingCompactionTitle": "대기 메시지 압축",
"behavior.pendingCompactionDescription": "버퍼가 임계값을 초과할 때 LLM을 사용하여 이전 그룹 메시지를 자동으로 요약합니다",
"behavior.pendingCompactionThreshold": "임계값",
@@ -36,9 +36,13 @@
"blockReply": "도구 호출 중 중간 텍스트 표시",
"blockReplyHint": "도구 호출 중 부분 어시스턴트 텍스트를 스트리밍합니다.",
"blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 스트리밍됩니다.",
"intentClassify": "의도 분류",
"intentClassifyHint": "에이전트로 라우팅하기 전에 사용자 의도를 분류합니다.",
"intentClassifyInfo": "에이전트 라우팅 및 동작 최적화를 위해 메시지가 분류됩니다."
"intentClassify": "에이전트 작업 중 메시지 분류",
"intentClassifyHint": "에이전트가 이전 요청을 처리 중일 때 새 메시지를 상태 질문, 취소, 추가 지시, 새 요청으로 분류합니다.",
"intentClassifyInfo": "이 기능은 세션이 작업 중일 때만 적용되며, 요청에 팀 워크플로가 필요한지는 판단하지 않습니다.",
"teamWorkClassify": "Team Work 분류",
"teamWorkClassifyHint": "새 요청을 직접 처리 또는 team/delegate 워크플로로 분류합니다.",
"teamWorkClassifyInfo": "요청이 현재 에이전트의 범위를 넘거나 분담이 필요하면 시스템이 embedding을 근거로 사용하고 모델이 직접 처리와 team/delegate 워크플로 중 하나를 결정하게 합니다. 시스템은 lead/member 권한도 확인합니다. member는 팀에서 member request와 auto-dispatch를 모두 켠 경우에만 도움 요청을 만들 수 있습니다.",
"teamWorkClassifyEmbeddingRequired": "Team Work 분류를 켜려면 먼저 embedding을 설정해야 합니다."
},
"kg": {
"title": "지식 그래프",
+7 -3
View File
@@ -59,7 +59,7 @@
"agents.workspaceTip": "Thư mục gốc cho các thao tác tệp của agent. Thư mục con theo người dùng được tạo tự động.",
"agents.restrictToWorkspace": "Giới hạn trong workspace",
"agents.restrictToWorkspaceTip": "Ngăn agent đọc hoặc ghi tệp ngoài đường dẫn workspace.",
"agents.intentClassify": "Phân loại ý định",
"agents.intentClassify": "Phân loại tin nhắn khi agent đang bận",
"agents.saveError": "Lưu mặc định agent thất bại",
"agents.subagents.title": "Subagent",
@@ -122,8 +122,12 @@
"behavior.uxTitle": "Hành vi giao diện",
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
"behavior.intentClassifyHint": "Phân loại ý định người dùng trước khi định tuyến để giảm các lần gọi agent không cần thiết.",
"behavior.intentClassifyInfo": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.",
"behavior.intentClassifyHint": "Khi agent đang xử lý một lượt trước đó, phân loại tin nhắn mới thành hỏi trạng thái, huỷ, bổ sung hướng dẫn hoặc yêu cầu mới.",
"behavior.intentClassifyInfo": "Tính năng này chỉ áp dụng khi session đang bận; nó không quyết định yêu cầu có cần làm việc theo team hay không.",
"behavior.teamWorkClassify": "Phân Loại Team Work",
"behavior.teamWorkClassifyHint": "Phân loại yêu cầu mới thành tự xử lý hoặc làm theo team/delegate workflow.",
"behavior.teamWorkClassifyInfo": "Khi yêu cầu vượt chuyên môn hoặc nên chia việc, hệ thống dùng embedding làm bằng chứng rồi để model quyết định tự xử lý hay dùng team/delegate workflow. Hệ thống xét cả quyền lead/member; member chỉ được tạo request nhờ hỗ trợ khi team đã bật Yêu cầu thành viên và Tự động phân phối.",
"behavior.teamWorkClassifyEmbeddingRequired": "Cần cấu hình embedding trước khi bật Phân Loại Team Work.",
"behavior.chatTitle": "Gửi tin nhắn giống người hơn",
"behavior.chatDescription": "Xác nhận sidecar, tiến độ trung gian và tách phản hồi cuối an toàn cho kênh không streaming",
"behavior.chatEnabled": "Bật hành vi gửi tin theo kênh",
@@ -30,9 +30,13 @@
"ux": {
"title": "Hành vi UX",
"description": "Điều khiển cách gateway hiển thị hoạt động của agent cho người dùng.",
"intentClassify": "Phân loại ý định",
"intentClassifyHint": "Phân loại ý định người dùng trước khi chuyển tới agent.",
"intentClassifyInfo": "Tin nhắn được phân loại để tối ưu định tuyến agent."
"intentClassify": "Phân loại tin nhắn khi agent đang bận",
"intentClassifyHint": "Khi agent đang xử lý một lượt trước đó, phân loại tin nhắn mới thành hỏi trạng thái, huỷ, bổ sung hướng dẫn hoặc yêu cầu mới.",
"intentClassifyInfo": "Tính năng này chỉ áp dụng khi session đang bận; nó không quyết định yêu cầu có cần làm việc theo team hay không.",
"teamWorkClassify": "Phân Loại Team Work",
"teamWorkClassifyHint": "Phân loại yêu cầu mới thành tự xử lý hoặc làm theo team/delegate workflow.",
"teamWorkClassifyInfo": "Khi yêu cầu vượt chuyên môn hoặc nên chia việc, hệ thống dùng embedding làm bằng chứng rồi để model quyết định tự xử lý hay dùng team/delegate workflow. Hệ thống xét cả quyền lead/member; member chỉ được tạo request nhờ hỗ trợ khi team đã bật Yêu cầu thành viên và Tự động phân phối.",
"teamWorkClassifyEmbeddingRequired": "Cần cấu hình embedding trước khi bật Phân Loại Team Work."
},
"kg": {
"title": "Đồ thị tri thức",
+7 -3
View File
@@ -59,7 +59,7 @@
"agents.workspaceTip": "Agent 文件操作的根目录,运行时自动创建每用户子目录。",
"agents.restrictToWorkspace": "限制在工作区内",
"agents.restrictToWorkspaceTip": "阻止 Agent 读写工作区路径以外的文件。",
"agents.intentClassify": "意图分类",
"agents.intentClassify": "Agent 忙碌时的消息分类",
"agents.saveError": "保存 Agent 默认值失败",
"agents.subagents.title": "子 Agent",
@@ -122,8 +122,12 @@
"behavior.uxTitle": "界面行为",
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
"behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。",
"behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 Agent。",
"behavior.intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
"behavior.intentClassifyInfo": "此功能只在会话忙碌时生效;它不会判断请求是否需要团队工作流。",
"behavior.teamWorkClassify": "Team Work 分类",
"behavior.teamWorkClassifyHint": "将新请求分类为自行处理或 team/delegate 工作流。",
"behavior.teamWorkClassifyInfo": "当请求超出当前 Agent 的职责范围或适合拆分协作时,系统使用 embedding 作为证据,并让模型决定自行处理还是使用 team/delegate 工作流。系统还会检查 lead/member 权限;member 只有在团队启用成员请求并开启自动分配时才能创建求助 request。",
"behavior.teamWorkClassifyEmbeddingRequired": "需要先配置 embedding 才能启用 Team Work 分类。",
"behavior.chatTitle": "拟人化频道投递",
"behavior.chatDescription": "为非流式频道提供 sidecar 确认、中间进度和安全的最终回复拆分",
"behavior.chatEnabled": "启用频道投递行为",
@@ -30,9 +30,13 @@
"ux": {
"title": "UX行为",
"description": "控制网关如何向用户展示Agent活动。",
"intentClassify": "意图分类",
"intentClassifyHint": "在路由到Agent之前分类用户意图。",
"intentClassifyInfo": "消息被分类以优化Agent路由和行为。"
"intentClassify": "Agent 忙碌时的消息分类",
"intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
"intentClassifyInfo": "此功能只在会话忙碌时生效;它不会判断请求是否需要团队工作流。",
"teamWorkClassify": "Team Work 分类",
"teamWorkClassifyHint": "将新请求分类为自行处理或 team/delegate 工作流。",
"teamWorkClassifyInfo": "当请求超出当前 Agent 的职责范围或适合拆分协作时,系统使用 embedding 作为证据,并让模型决定自行处理还是使用 team/delegate 工作流。系统还会检查 lead/member 权限;member 只有在团队启用成员请求并开启自动分配时才能创建求助 request。",
"teamWorkClassifyEmbeddingRequired": "需要先配置 embedding 才能启用 Team Work 分类。"
},
"kg": {
"title": "知识图谱",
@@ -29,6 +29,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
// UX toggles (from gateway + agents.defaults)
const [ux, setUx] = useState({
intent_classify: ag.intent_classify !== false,
team_work_classify: gw.team_work_classify === true,
});
// Rate limiting (from gateway)
@@ -62,6 +63,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
useEffect(() => {
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 },
@@ -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 (