mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat: add team work classification routing (#1379)
Approved by github-maintain bot. Clean team work classification feature with comprehensive tests and i18n.
This commit is contained in:
1 parent
f31214f5ce
commit
f826738ee6
34 files changed
+1715
-57
No files matched your search
+3
-2
@@ -372,7 +372,7 @@ func runGateway() {
|
|||||||
slog.Debug("skipping MCP database init: pgStores.MCP is nil")
|
slog.Debug("skipping MCP database init: pgStores.MCP is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
setupMemoryEmbeddings(pgStores, providerRegistry)
|
teamWorkEmbedder := setupMemoryEmbeddings(pgStores, providerRegistry)
|
||||||
usageCapSvc := usagecaps.NewService(pgStores.UsageCaps, pgStores.Providers)
|
usageCapSvc := usagecaps.NewService(pgStores.UsageCaps, pgStores.Providers)
|
||||||
|
|
||||||
// Resolve background provider for consolidation + vault enrichment.
|
// Resolve background provider for consolidation + vault enrichment.
|
||||||
@@ -532,6 +532,7 @@ func runGateway() {
|
|||||||
domainBus: domainBus,
|
domainBus: domainBus,
|
||||||
usageCapSvc: usageCapSvc,
|
usageCapSvc: usageCapSvc,
|
||||||
audioMgr: audioMgr,
|
audioMgr: audioMgr,
|
||||||
|
teamWorkEmbedder: teamWorkEmbedder,
|
||||||
}
|
}
|
||||||
|
|
||||||
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
|
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
@@ -639,7 +640,7 @@ func runGateway() {
|
|||||||
// Register all RPC methods
|
// Register all RPC methods
|
||||||
server.SetLogTee(logTee)
|
server.SetLogTee(logTee)
|
||||||
server.SetRuntimeLogsHandler(httpapi.NewRuntimeLogsHandler(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).
|
// Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history).
|
||||||
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
|
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
@@ -27,7 +28,7 @@ import (
|
|||||||
// and routes them through the scheduler/agent loop, then publishes the response back.
|
// 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
|
// 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.
|
// (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")
|
slog.Info("inbound message consumer started")
|
||||||
|
|
||||||
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
|
// 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,
|
ChannelMgr: channelMgr,
|
||||||
MsgBus: msgBus,
|
MsgBus: msgBus,
|
||||||
TeamStore: teamStore,
|
TeamStore: teamStore,
|
||||||
|
AgentLinkStore: agentLinkStore,
|
||||||
AgentStore: agentStore,
|
AgentStore: agentStore,
|
||||||
SessStore: sessStore,
|
SessStore: sessStore,
|
||||||
PostTurn: postTurn,
|
PostTurn: postTurn,
|
||||||
@@ -61,6 +63,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
|||||||
SubagentMgr: subagentMgr,
|
SubagentMgr: subagentMgr,
|
||||||
UsageCaps: usageCapSvc,
|
UsageCaps: usageCapSvc,
|
||||||
ProviderReg: providerReg,
|
ProviderReg: providerReg,
|
||||||
|
TeamWorkEmbedder: teamWorkEmbedder,
|
||||||
GetAnnounceMu: getAnnounceMu,
|
GetAnnounceMu: getAnnounceMu,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
@@ -23,6 +24,7 @@ type ConsumerDeps struct {
|
|||||||
ChannelMgr *channels.Manager
|
ChannelMgr *channels.Manager
|
||||||
MsgBus *bus.MessageBus
|
MsgBus *bus.MessageBus
|
||||||
TeamStore store.TeamStore
|
TeamStore store.TeamStore
|
||||||
|
AgentLinkStore store.AgentLinkStore
|
||||||
AgentStore store.AgentStore
|
AgentStore store.AgentStore
|
||||||
SessStore store.SessionStore
|
SessStore store.SessionStore
|
||||||
PostTurn tools.PostTurnProcessor
|
PostTurn tools.PostTurnProcessor
|
||||||
@@ -32,6 +34,7 @@ type ConsumerDeps struct {
|
|||||||
SubagentMgr *tools.SubagentManager
|
SubagentMgr *tools.SubagentManager
|
||||||
UsageCaps *usagecaps.Service
|
UsageCaps *usagecaps.Service
|
||||||
ProviderReg *providers.Registry
|
ProviderReg *providers.Registry
|
||||||
|
TeamWorkEmbedder memory.EmbeddingProvider
|
||||||
BgWg sync.WaitGroup
|
BgWg sync.WaitGroup
|
||||||
GetAnnounceMu func(string) *sync.Mutex
|
GetAnnounceMu func(string) *sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -407,11 +407,16 @@ func processNormalMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inboundMessage := msg.Content
|
||||||
|
|
||||||
// Inject tenant context from channel instance so all store queries are tenant-scoped.
|
// Inject tenant context from channel instance so all store queries are tenant-scoped.
|
||||||
if msg.TenantID != uuid.Nil {
|
if msg.TenantID != uuid.Nil {
|
||||||
ctx = store.WithTenantID(ctx, msg.TenantID)
|
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.
|
// Inject post-turn dispatch tracker so team task creates are deferred.
|
||||||
ptd := tools.NewPendingTeamDispatch()
|
ptd := tools.NewPendingTeamDispatch()
|
||||||
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
|
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
|
||||||
@@ -447,7 +452,7 @@ func processNormalMessage(
|
|||||||
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
|
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
|
||||||
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
|
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Message: msg.Content,
|
Message: inboundMessage,
|
||||||
Media: reqMedia,
|
Media: reqMedia,
|
||||||
ForwardMedia: fwdMedia,
|
ForwardMedia: fwdMedia,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
@@ -471,6 +476,7 @@ func processNormalMessage(
|
|||||||
ToolAllow: msg.ToolAllow,
|
ToolAllow: msg.ToolAllow,
|
||||||
TelegramManagerPermissions: msg.TelegramManagerPermissions,
|
TelegramManagerPermissions: msg.TelegramManagerPermissions,
|
||||||
ExtraSystemPrompt: extraPrompt,
|
ExtraSystemPrompt: extraPrompt,
|
||||||
|
TeamWorkDirective: gate.Directive,
|
||||||
SkillFilter: skillFilter,
|
SkillFilter: skillFilter,
|
||||||
}, scheduler.ScheduleOpts{
|
}, scheduler.ScheduleOpts{
|
||||||
MaxConcurrent: maxConcurrent,
|
MaxConcurrent: maxConcurrent,
|
||||||
@@ -615,7 +621,7 @@ func processNormalMessage(
|
|||||||
if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
|
if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
|
||||||
go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent)
|
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 {
|
func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, userID, peerKind, channelType, agentKey string) channels.DeliveryRuntime {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
@@ -39,4 +40,5 @@ type gatewayDeps struct {
|
|||||||
usageCapSvc *usagecaps.Service
|
usageCapSvc *usagecaps.Service
|
||||||
audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler
|
audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler
|
||||||
ttsHandler *httpapi.TTSHandler // nil if TTS not configured; for hot-reload
|
ttsHandler *httpapi.TTSHandler // nil if TTS not configured; for hot-reload
|
||||||
|
teamWorkEmbedder memory.EmbeddingProvider
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (d *gatewayDeps) runLifecycle(
|
|||||||
d.channelMgr.SetContactCollector(contactCollector)
|
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.
|
// 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).
|
// Runs in both editions: Standard (PG, concurrency=4) and Lite (SQLite, concurrency=1).
|
||||||
|
|||||||
@@ -10,19 +10,21 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
|
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||||
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
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()
|
router := server.Router()
|
||||||
|
|
||||||
// Phase 1: Core methods
|
// Phase 1: Core methods
|
||||||
chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus)
|
chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus)
|
||||||
chatMethods.SetAudioManager(audioMgr) // Wire TTS auto-apply for WS responses
|
chatMethods.SetAudioManager(audioMgr) // Wire TTS auto-apply for WS responses
|
||||||
chatMethods.SetUsageCapService(usageCapSvc)
|
chatMethods.SetUsageCapService(usageCapSvc)
|
||||||
|
chatMethods.SetTeamWorkClassification(agentStore, teamStore, agentLinkStore, teamWorkEmbedder)
|
||||||
chatMethods.Register(router)
|
chatMethods.Register(router)
|
||||||
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
|
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
|
||||||
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
|
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||||
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
||||||
@@ -370,9 +371,11 @@ func wireTracingAndCron(
|
|||||||
func setupMemoryEmbeddings(
|
func setupMemoryEmbeddings(
|
||||||
pgStores *store.Stores,
|
pgStores *store.Stores,
|
||||||
providerRegistry *providers.Registry,
|
providerRegistry *providers.Registry,
|
||||||
) {
|
) memory.EmbeddingProvider {
|
||||||
|
var resolved memory.EmbeddingProvider
|
||||||
if pgStores.Memory != nil {
|
if pgStores.Memory != nil {
|
||||||
if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil {
|
if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil {
|
||||||
|
resolved = embProvider
|
||||||
pgStores.Memory.SetEmbeddingProvider(embProvider)
|
pgStores.Memory.SetEmbeddingProvider(embProvider)
|
||||||
slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model())
|
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")
|
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.
|
// seedSystemConfigs ensures system_configs has all expected keys for all tenants.
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
|
|||||||
setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs)
|
setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs)
|
||||||
setBool("gateway.block_reply", cfg.Gateway.BlockReply)
|
setBool("gateway.block_reply", cfg.Gateway.BlockReply)
|
||||||
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
|
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
|
||||||
|
setBool("gateway.team_work_classify", cfg.Gateway.TeamWorkClassify)
|
||||||
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
|
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
|
||||||
|
|
||||||
// Background workers
|
// Background workers
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCa
|
|||||||
loadSessionHistory: l.makeLoadSessionHistory(),
|
loadSessionHistory: l.makeLoadSessionHistory(),
|
||||||
resolveWorkspace: l.makeResolveWorkspace(req),
|
resolveWorkspace: l.makeResolveWorkspace(req),
|
||||||
loadContextFiles: l.makeLoadContextFiles(),
|
loadContextFiles: l.makeLoadContextFiles(),
|
||||||
buildMessages: l.makeBuildMessages(),
|
buildMessages: l.makeBuildMessages(req),
|
||||||
enrichMedia: l.makeEnrichMedia(req),
|
enrichMedia: l.makeEnrichMedia(req),
|
||||||
injectReminders: l.makeInjectReminders(req),
|
injectReminders: l.makeInjectReminders(req),
|
||||||
buildFilteredTools: l.makeBuildFilteredTools(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) {
|
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,
|
msgs, _ := l.buildMessages(ctx, history, summary,
|
||||||
input.Message, input.ExtraSystemPrompt,
|
input.Message, input.ExtraSystemPrompt,
|
||||||
input.SessionKey, input.Channel, input.ChannelType,
|
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))
|
"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
|
// One guarded retry when MCP task tools are available but the model
|
||||||
// returns text-only instead of tool calls.
|
// 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)
|
slog.Info("debug.llm.retry_guard", "retry_eligible", retryEligible)
|
||||||
if retryEligible {
|
if retryEligible {
|
||||||
retryReq := chatReq
|
retryReq := chatReq
|
||||||
|
|||||||
@@ -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)
|
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
|
Stream bool // whether to stream response chunks
|
||||||
ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.)
|
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
|
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)
|
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")
|
ToolAllow []string // per-group tool allow list (nil = no restriction, supports "group:xxx")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -433,6 +433,7 @@ type GatewayConfig struct {
|
|||||||
BlockReply *bool `json:"block_reply,omitempty"` // deliver intermediate text during tool iterations (default false)
|
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)
|
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)
|
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)
|
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)
|
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.
|
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.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
|
|||||||
integer("gateway.inbound_debounce_ms", &c.Gateway.InboundDebounceMs)
|
integer("gateway.inbound_debounce_ms", &c.Gateway.InboundDebounceMs)
|
||||||
boolean("gateway.block_reply", &c.Gateway.BlockReply)
|
boolean("gateway.block_reply", &c.Gateway.BlockReply)
|
||||||
boolean("gateway.tool_status", &c.Gateway.ToolStatus)
|
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.task_recovery_interval_sec", &c.Gateway.TaskRecoveryIntervalSec)
|
||||||
integer("gateway.webhook_async_timeout_sec", &c.Gateway.WebhookAsyncTimeoutSec)
|
integer("gateway.webhook_async_timeout_sec", &c.Gateway.WebhookAsyncTimeoutSec)
|
||||||
integer("gateway.webhook_sync_timeout_sec", &c.Gateway.WebhookSyncTimeoutSec)
|
integer("gateway.webhook_sync_timeout_sec", &c.Gateway.WebhookSyncTimeoutSec)
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import (
|
|||||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/memory"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/teamworkclassify"
|
||||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||||
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
||||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||||
@@ -26,15 +28,19 @@ import (
|
|||||||
|
|
||||||
// ChatMethods handles chat.send, chat.history, chat.abort, chat.inject.
|
// ChatMethods handles chat.send, chat.history, chat.abort, chat.inject.
|
||||||
type ChatMethods struct {
|
type ChatMethods struct {
|
||||||
agents *agent.Router
|
agents *agent.Router
|
||||||
sessions store.SessionStore
|
sessions store.SessionStore
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
rateLimiter *gateway.RateLimiter
|
rateLimiter *gateway.RateLimiter
|
||||||
eventBus bus.EventPublisher
|
eventBus bus.EventPublisher
|
||||||
postTurn tools.PostTurnProcessor
|
postTurn tools.PostTurnProcessor
|
||||||
audioMgr *audio.Manager // for TTS auto-apply on WS responses (nil = disabled)
|
audioMgr *audio.Manager // for TTS auto-apply on WS responses (nil = disabled)
|
||||||
usageCaps *usagecaps.Service
|
usageCaps *usagecaps.Service
|
||||||
debouncer *chatDebouncer
|
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 {
|
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
|
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.
|
// SetPostTurnProcessor sets the post-turn processor for team task dispatch.
|
||||||
func (m *ChatMethods) SetPostTurnProcessor(pt tools.PostTurnProcessor) {
|
func (m *ChatMethods) SetPostTurnProcessor(pt tools.PostTurnProcessor) {
|
||||||
m.postTurn = pt
|
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) {
|
func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) {
|
||||||
if len(requests) == 0 {
|
if len(requests) == 0 {
|
||||||
return
|
return
|
||||||
@@ -279,6 +340,8 @@ func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) {
|
|||||||
if userID != "" {
|
if userID != "" {
|
||||||
runCtxBase = store.WithUserID(runCtxBase, 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)
|
// Inject team dispatch tracker: gates team_tasks create (must search/list first)
|
||||||
// and defers task dispatch to post-turn.
|
// and defers task dispatch to post-turn.
|
||||||
runCtxBase, drainTeamDispatch := tools.InjectTeamDispatch(runCtxBase, m.postTurn)
|
runCtxBase, drainTeamDispatch := tools.InjectTeamDispatch(runCtxBase, m.postTurn)
|
||||||
@@ -324,16 +387,17 @@ func (m *ChatMethods) dispatchChatSends(requests []chatSendRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result, err := loop.Run(runCtx, agent.RunRequest{
|
result, err := loop.Run(runCtx, agent.RunRequest{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Message: message,
|
Message: message,
|
||||||
Media: mediaFiles,
|
Media: mediaFiles,
|
||||||
Channel: "ws",
|
Channel: "ws",
|
||||||
ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID())
|
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
|
WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Stream: params.Stream,
|
Stream: params.Stream,
|
||||||
InjectCh: injectCh,
|
TeamWorkDirective: gate.directive,
|
||||||
|
InjectCh: injectCh,
|
||||||
// Wire trace ID back to the active run so force-abort can mark the
|
// 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.
|
// correct trace as cancelled if the goroutine does not exit within 3s.
|
||||||
OnTraceCreated: func(traceID uuid.UUID) {
|
OnTraceCreated: func(traceID uuid.UUID) {
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
package teamworkclassify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Decision string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DecisionSelf Decision = "self"
|
||||||
|
DecisionTeam Decision = "team"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeSpawn Mode = "spawn"
|
||||||
|
ModeDelegate Mode = "delegate"
|
||||||
|
ModeTeam Mode = "team"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultCloseMargin = 0.08
|
||||||
|
defaultTeamThreshold = 0.35
|
||||||
|
defaultEvidenceTimeout = 8 * time.Second
|
||||||
|
defaultArbiterTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type Embedder interface {
|
||||||
|
Embed(ctx context.Context, texts []string) ([][]float32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Mode Mode
|
||||||
|
Message string
|
||||||
|
CurrentAgent Profile
|
||||||
|
SelfTools []Profile
|
||||||
|
Team Profile
|
||||||
|
Members []Profile
|
||||||
|
Delegates []Profile
|
||||||
|
CollaborationTools []Profile
|
||||||
|
TeamRole string
|
||||||
|
CanAssignTeamTasks bool
|
||||||
|
MemberRequestsEnabled bool
|
||||||
|
MemberRequestsAutoDispatch bool
|
||||||
|
Embedder Embedder
|
||||||
|
CloseMargin float64
|
||||||
|
TeamThreshold float64
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Decision Decision
|
||||||
|
Confidence float64
|
||||||
|
Reason string
|
||||||
|
SelfScore float64
|
||||||
|
CollaborationScore float64
|
||||||
|
Mode Mode
|
||||||
|
RequiredTool string
|
||||||
|
WorkflowHint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Classify(ctx context.Context, input Input) Result {
|
||||||
|
if input.Mode == "" || input.Mode == ModeSpawn {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "no team or delegate capability"}
|
||||||
|
}
|
||||||
|
if input.Embedder == nil {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "embedding unavailable"}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Message) == "" {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "empty message"}
|
||||||
|
}
|
||||||
|
if looksCasualOrSmallDirect(input.Message) {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "message looks casual or direct"}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := input.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultEvidenceTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
selfDocs, collaborationDocs := splitProfileDocuments(input)
|
||||||
|
if len(selfDocs) == 0 || len(collaborationDocs) == 0 {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "insufficient collaboration profile"}
|
||||||
|
}
|
||||||
|
|
||||||
|
texts := append([]string{input.Message}, append(selfDocs, collaborationDocs...)...)
|
||||||
|
vectors, err := input.Embedder.Embed(ctx, texts)
|
||||||
|
if err != nil || len(vectors) != len(texts) {
|
||||||
|
return Result{Decision: DecisionSelf, Reason: "embedding failed"}
|
||||||
|
}
|
||||||
|
query := vectors[0]
|
||||||
|
selfScore := bestCosine(query, vectors[1:1+len(selfDocs)])
|
||||||
|
collabScore := bestCosine(query, vectors[1+len(selfDocs):])
|
||||||
|
|
||||||
|
margin := input.CloseMargin
|
||||||
|
if margin <= 0 {
|
||||||
|
margin = DefaultCloseMargin
|
||||||
|
}
|
||||||
|
threshold := input.TeamThreshold
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultTeamThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := collabScore - selfScore
|
||||||
|
bestScore := math.Max(selfScore, collabScore)
|
||||||
|
switch {
|
||||||
|
case math.Abs(diff) <= margin:
|
||||||
|
return Result{
|
||||||
|
Decision: DecisionSelf,
|
||||||
|
Confidence: bestScore,
|
||||||
|
Reason: "profiles are close; defaulting to self",
|
||||||
|
SelfScore: selfScore,
|
||||||
|
CollaborationScore: collabScore,
|
||||||
|
Mode: input.Mode,
|
||||||
|
}
|
||||||
|
case diff > margin && collabScore >= threshold:
|
||||||
|
return Result{
|
||||||
|
Decision: DecisionTeam,
|
||||||
|
Confidence: diff,
|
||||||
|
Reason: "request is closer to team/delegate capability",
|
||||||
|
SelfScore: selfScore,
|
||||||
|
CollaborationScore: collabScore,
|
||||||
|
Mode: input.Mode,
|
||||||
|
RequiredTool: requiredToolForMode(input.Mode),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Result{
|
||||||
|
Decision: DecisionSelf,
|
||||||
|
Confidence: -diff,
|
||||||
|
Reason: "request is closer to current agent capability",
|
||||||
|
SelfScore: selfScore,
|
||||||
|
CollaborationScore: collabScore,
|
||||||
|
Mode: input.Mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClassifyWithLLM(ctx context.Context, input Input, provider providers.Provider, model string, caps *usagecaps.Service) Result {
|
||||||
|
fallback := Classify(ctx, input)
|
||||||
|
if input.Mode == "" || input.Mode == ModeSpawn || provider == nil || strings.TrimSpace(model) == "" {
|
||||||
|
return forceSelfDecision(fallback, "arbiter unavailable: ")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := input.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultArbiterTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req := providers.ChatRequest{
|
||||||
|
Messages: BuildArbiterMessages(input, fallback),
|
||||||
|
Model: model,
|
||||||
|
Options: map[string]any{
|
||||||
|
providers.OptMaxTokens: 300,
|
||||||
|
providers.OptTemperature: 0.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
resp *providers.ChatResponse
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if caps != nil {
|
||||||
|
resp, err = caps.Chat(ctx, provider, req, usagecaps.ChatOptions{
|
||||||
|
ModelID: model,
|
||||||
|
Purpose: "team-work-classify",
|
||||||
|
MaxOutputTokens: 300,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp, err = provider.Chat(ctx, req)
|
||||||
|
}
|
||||||
|
if err != nil || resp == nil {
|
||||||
|
slog.Warn("team_work_classify: arbiter call failed",
|
||||||
|
"model", model,
|
||||||
|
"provider_type", fmt.Sprintf("%T", provider),
|
||||||
|
"has_usage_caps", caps != nil,
|
||||||
|
"mode", input.Mode,
|
||||||
|
"timeout", timeout.String(),
|
||||||
|
"response_nil", resp == nil,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
return forceSelfDecision(fallback, "arbiter_failed: ")
|
||||||
|
}
|
||||||
|
result, err := ParseArbiterResult(resp.Content, input.Mode)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("team_work_classify: arbiter parse failed",
|
||||||
|
"model", model,
|
||||||
|
"provider_type", fmt.Sprintf("%T", provider),
|
||||||
|
"mode", input.Mode,
|
||||||
|
"content_len", len(resp.Content),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
return forceSelfDecision(fallback, "arbiter_parse_failed: ")
|
||||||
|
}
|
||||||
|
result.SelfScore = fallback.SelfScore
|
||||||
|
result.CollaborationScore = fallback.CollaborationScore
|
||||||
|
if result.Reason == "" {
|
||||||
|
result.Reason = fallback.Reason
|
||||||
|
}
|
||||||
|
return applyTeamPermissionGate(input, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceSelfDecision(evidence Result, reasonPrefix string) Result {
|
||||||
|
evidence.Decision = DecisionSelf
|
||||||
|
evidence.RequiredTool = ""
|
||||||
|
evidence.WorkflowHint = ""
|
||||||
|
evidence.Reason = reasonPrefix + evidence.Reason
|
||||||
|
return evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTeamPermissionGate(input Input, result Result) Result {
|
||||||
|
if result.Decision != DecisionTeam {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if result.WorkflowHint == "" {
|
||||||
|
result.WorkflowHint = workflowHintForInput(input)
|
||||||
|
}
|
||||||
|
if input.Mode != ModeTeam {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
|
||||||
|
if role == "" || role == "lead" || input.CanAssignTeamTasks {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Decision = DecisionSelf
|
||||||
|
result.RequiredTool = ""
|
||||||
|
result.WorkflowHint = ""
|
||||||
|
reason := "member lacks auto-dispatch team request permission"
|
||||||
|
if !input.MemberRequestsEnabled {
|
||||||
|
reason = "member lacks team request permission"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.Reason) == "" {
|
||||||
|
result.Reason = reason
|
||||||
|
} else {
|
||||||
|
result.Reason = reason + ": " + result.Reason
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func workflowHintForInput(input Input) string {
|
||||||
|
switch input.Mode {
|
||||||
|
case ModeDelegate:
|
||||||
|
return "Use the `delegate` tool with an available linked agent. Do not invent agent keys."
|
||||||
|
case ModeTeam:
|
||||||
|
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
|
||||||
|
if role != "" && role != "lead" && !input.CanAssignTeamTasks {
|
||||||
|
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
|
||||||
|
return `As a team member, do not create or assign general team tasks. Use team_tasks(action="create", task_type="request", ...) to ask a teammate for help. This team's member requests auto-dispatch to the assignee after creation.`
|
||||||
|
}
|
||||||
|
if input.MemberRequestsEnabled {
|
||||||
|
return "As a team member, member requests are enabled but auto-dispatch is disabled; choose self because request tasks would stay pending for leader review and may not run in this turn."
|
||||||
|
}
|
||||||
|
return "As a team member, you cannot create or assign general team tasks, and member request tasks are disabled. Handle the request directly or explain that a lead must coordinate the team work."
|
||||||
|
}
|
||||||
|
return `Use team_tasks(action="search" or "list") first, then team_tasks(action="create", ...) to assign work to an appropriate team member when new team work is required.`
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildArbiterMessages(input Input, evidence Result) []providers.Message {
|
||||||
|
system := `You are a Team Work routing arbiter.
|
||||||
|
Return ONLY JSON. Do not answer the user.
|
||||||
|
Choose exactly one decision:
|
||||||
|
- self: the current agent should handle the request directly.
|
||||||
|
- team: the current agent must use the available team/delegate workflow.
|
||||||
|
Never return ask. If uncertain, choose self so normal chat is not disrupted.
|
||||||
|
|
||||||
|
Strict routing policy:
|
||||||
|
- Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files, documents, results, or prior team outputs.
|
||||||
|
- Choose self when the current agent can answer by using existing files, existing task results, or already completed team work without assigning new work.
|
||||||
|
- Do not choose team only because files are located in the team workspace.
|
||||||
|
- Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output.
|
||||||
|
- Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work.
|
||||||
|
- Choose team when the request clearly needs new work from multiple roles, not merely synthesis of existing material.
|
||||||
|
- Permission matters: a team member who is not lead cannot assign or create general team tasks.
|
||||||
|
- If the current agent is a member and member requests are disabled, choose self for requests to assign, split, coordinate, or ask teammates.
|
||||||
|
- If the current agent is a member and member requests are enabled but auto-dispatch is disabled, choose self because pending leader review is not an immediate executable workflow.
|
||||||
|
- If the current agent is a member and member requests plus auto-dispatch are enabled, choose team only for a request-help workflow using task_type="request"; do not treat it as lead-style assignment.
|
||||||
|
- When self_score and collaboration_score are close, choose self unless there is a clear team signal.`
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("User request:\n")
|
||||||
|
b.WriteString(input.Message)
|
||||||
|
b.WriteString("\n\nRouting mode: ")
|
||||||
|
b.WriteString(string(input.Mode))
|
||||||
|
b.WriteString("\nRequired workflow tool when team is chosen: ")
|
||||||
|
b.WriteString(requiredToolForMode(input.Mode))
|
||||||
|
if input.Mode == ModeTeam {
|
||||||
|
b.WriteString("\n\nTeam permission context:\n")
|
||||||
|
b.WriteString("current_agent_team_role: ")
|
||||||
|
b.WriteString(firstNonEmpty(input.TeamRole, "unknown"))
|
||||||
|
b.WriteString("\ncan_assign_team_tasks: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%t", input.CanAssignTeamTasks))
|
||||||
|
b.WriteString("\nmember_requests_enabled: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsEnabled))
|
||||||
|
b.WriteString("\nmember_requests_auto_dispatch: ")
|
||||||
|
b.WriteString(fmt.Sprintf("%t", input.MemberRequestsAutoDispatch))
|
||||||
|
if hint := workflowHintForInput(input); hint != "" {
|
||||||
|
b.WriteString("\nworkflow_hint: ")
|
||||||
|
b.WriteString(hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nEmbedding evidence:\n")
|
||||||
|
b.WriteString(fmt.Sprintf("self_score: %.4f\n", evidence.SelfScore))
|
||||||
|
b.WriteString(fmt.Sprintf("collaboration_score: %.4f\n", evidence.CollaborationScore))
|
||||||
|
b.WriteString("embedding_fallback_decision: ")
|
||||||
|
b.WriteString(string(evidence.Decision))
|
||||||
|
b.WriteString("\n\nCurrent agent and direct capability:\n")
|
||||||
|
for _, doc := range appendProfileDocs(input.CurrentAgent, input.SelfTools) {
|
||||||
|
b.WriteString("---\n")
|
||||||
|
b.WriteString(doc)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\nTeam/delegate/tool capability:\n")
|
||||||
|
collaborationProfiles := append([]Profile{}, input.Members...)
|
||||||
|
collaborationProfiles = append(collaborationProfiles, input.Delegates...)
|
||||||
|
collaborationProfiles = append(collaborationProfiles, input.CollaborationTools...)
|
||||||
|
for _, doc := range appendProfileDocs(input.Team, collaborationProfiles) {
|
||||||
|
b.WriteString("---\n")
|
||||||
|
b.WriteString(doc)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\nReturn JSON shape:\n")
|
||||||
|
b.WriteString(`{"decision":"self|team","confidence":0.0,"mode":"team|delegate","required_tool":"team_tasks|delegate","workflow_hint":"short permission-aware tool guidance","reason":"short internal reason"}`)
|
||||||
|
|
||||||
|
return []providers.Message{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: b.String()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendProfileDocs(first Profile, rest []Profile) []string {
|
||||||
|
var docs []string
|
||||||
|
if doc := renderProfile(first); doc != "" {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
for _, p := range rest {
|
||||||
|
if doc := renderProfile(p); doc != "" {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseArbiterResult(content string, mode Mode) (Result, error) {
|
||||||
|
raw := strings.TrimSpace(content)
|
||||||
|
if start := strings.Index(raw, "{"); start >= 0 {
|
||||||
|
if end := strings.LastIndex(raw, "}"); end >= start {
|
||||||
|
raw = raw[start : end+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Decision string `json:"decision"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
RequiredTool string `json:"required_tool"`
|
||||||
|
WorkflowHint string `json:"workflow_hint"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
resultMode := Mode(strings.TrimSpace(parsed.Mode))
|
||||||
|
if resultMode == "" || resultMode == ModeSpawn {
|
||||||
|
resultMode = mode
|
||||||
|
}
|
||||||
|
requiredTool := strings.TrimSpace(parsed.RequiredTool)
|
||||||
|
if requiredTool == "" {
|
||||||
|
requiredTool = requiredToolForMode(resultMode)
|
||||||
|
}
|
||||||
|
switch Decision(strings.ToLower(strings.TrimSpace(parsed.Decision))) {
|
||||||
|
case DecisionTeam:
|
||||||
|
if resultMode != ModeTeam && resultMode != ModeDelegate {
|
||||||
|
resultMode = mode
|
||||||
|
}
|
||||||
|
if resultMode != ModeTeam && resultMode != ModeDelegate {
|
||||||
|
return Result{Decision: DecisionSelf, Mode: mode, Reason: "arbiter requested team without workflow mode"}, nil
|
||||||
|
}
|
||||||
|
return Result{
|
||||||
|
Decision: DecisionTeam,
|
||||||
|
Confidence: parsed.Confidence,
|
||||||
|
Reason: strings.TrimSpace(parsed.Reason),
|
||||||
|
Mode: resultMode,
|
||||||
|
RequiredTool: requiredTool,
|
||||||
|
WorkflowHint: strings.TrimSpace(parsed.WorkflowHint),
|
||||||
|
}, nil
|
||||||
|
case DecisionSelf:
|
||||||
|
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: strings.TrimSpace(parsed.Reason), Mode: mode}, nil
|
||||||
|
default:
|
||||||
|
return Result{Decision: DecisionSelf, Confidence: parsed.Confidence, Reason: "arbiter returned unsupported decision; defaulting to self", Mode: mode}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredToolForMode(mode Mode) string {
|
||||||
|
switch mode {
|
||||||
|
case ModeTeam:
|
||||||
|
return "team_tasks"
|
||||||
|
case ModeDelegate:
|
||||||
|
return "delegate"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksCasualOrSmallDirect(message string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(message))
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
actionMarkers := []string{
|
||||||
|
"hãy ", "hay ", "viết", "viet", "tạo", "tao", "làm", "lam",
|
||||||
|
"kiểm tra", "kiem tra", "phân tích", "phan tich", "soạn", "soan",
|
||||||
|
"dịch", "dich", "tìm", "tim", "lập kế hoạch", "lap ke hoach",
|
||||||
|
"triển khai", "trien khai", "thiết kế", "thiet ke", "sửa", "sua",
|
||||||
|
"đánh giá", "danh gia", "tóm tắt", "tom tat", "nghiên cứu", "nghien cuu",
|
||||||
|
"check", "create", "write", "analyze", "analyse", "fix", "build",
|
||||||
|
"plan", "research", "design", "review", "summarize", "summarise",
|
||||||
|
"查", "写", "创建", "分析", "修复", "设计", "总结",
|
||||||
|
"작성", "생성", "분석", "수정", "설계", "요약",
|
||||||
|
}
|
||||||
|
for _, marker := range actionMarkers {
|
||||||
|
if strings.Contains(s, marker) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
casualMarkers := []string{
|
||||||
|
"chào", "chao", "hello", "hi", "ok", "ừ", "uh", "cảm ơn", "cam on",
|
||||||
|
"thanks", "thank you", "xin lỗi", "sorry", "được rồi", "duoc roi",
|
||||||
|
}
|
||||||
|
for _, marker := range casualMarkers {
|
||||||
|
if strings.Contains(s, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len([]rune(s)) <= 80
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildProfileDocuments(input Input) []string {
|
||||||
|
selfDocs, collaborationDocs := splitProfileDocuments(input)
|
||||||
|
return append(selfDocs, collaborationDocs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitProfileDocuments(input Input) ([]string, []string) {
|
||||||
|
var selfDocs []string
|
||||||
|
if doc := renderProfile(input.CurrentAgent); doc != "" {
|
||||||
|
selfDocs = append(selfDocs, doc)
|
||||||
|
}
|
||||||
|
for _, p := range input.SelfTools {
|
||||||
|
if doc := renderProfile(p); doc != "" {
|
||||||
|
selfDocs = append(selfDocs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var collaborationDocs []string
|
||||||
|
if doc := renderProfile(input.Team); doc != "" {
|
||||||
|
collaborationDocs = append(collaborationDocs, doc)
|
||||||
|
}
|
||||||
|
for _, group := range [][]Profile{input.Members, input.Delegates, input.CollaborationTools} {
|
||||||
|
for _, p := range group {
|
||||||
|
if doc := renderProfile(p); doc != "" {
|
||||||
|
collaborationDocs = append(collaborationDocs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selfDocs, collaborationDocs
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderProfile(p Profile) string {
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
text := strings.TrimSpace(p.Text)
|
||||||
|
kind := strings.TrimSpace(p.Kind)
|
||||||
|
if name == "" && text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if kind != "" {
|
||||||
|
b.WriteString("kind: ")
|
||||||
|
b.WriteString(kind)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
b.WriteString("name: ")
|
||||||
|
b.WriteString(name)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
b.WriteString("description: ")
|
||||||
|
b.WriteString(text)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestCosine(query []float32, docs [][]float32) float64 {
|
||||||
|
best := -1.0
|
||||||
|
for _, doc := range docs {
|
||||||
|
score, err := cosine(query, doc)
|
||||||
|
if err == nil && score > best {
|
||||||
|
best = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func cosine(a, b []float32) (float64, error) {
|
||||||
|
if len(a) == 0 || len(a) != len(b) {
|
||||||
|
return 0, errors.New("dimension mismatch")
|
||||||
|
}
|
||||||
|
var dot, na, nb float64
|
||||||
|
for i := range a {
|
||||||
|
av := float64(a[i])
|
||||||
|
bv := float64(b[i])
|
||||||
|
dot += av * bv
|
||||||
|
na += av * av
|
||||||
|
nb += bv * bv
|
||||||
|
}
|
||||||
|
if na == 0 || nb == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(na) * math.Sqrt(nb)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package teamworkclassify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeEmbedder map[string][]float32
|
||||||
|
|
||||||
|
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||||
|
out := make([][]float32, 0, len(texts))
|
||||||
|
for _, text := range texts {
|
||||||
|
var vec []float32
|
||||||
|
for key, v := range f {
|
||||||
|
if strings.Contains(text, key) {
|
||||||
|
vec = v
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vec == nil {
|
||||||
|
vec = []float32{0, 0}
|
||||||
|
}
|
||||||
|
out = append(out, vec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySpawnModeSkipsToSelf(t *testing.T) {
|
||||||
|
result := Classify(context.Background(), Input{
|
||||||
|
Mode: ModeSpawn,
|
||||||
|
Message: "lập kế hoạch content và phân tích chiến lược",
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"lập kế hoạch": {1, 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
|
||||||
|
}
|
||||||
|
if result.Reason == "" {
|
||||||
|
t.Fatal("Reason is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyTeamWhenRequestMatchesTeamMemberOrToolProfile(t *testing.T) {
|
||||||
|
result := Classify(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "lập kế hoạch content và phân tích chiến lược cho chiến dịch mới",
|
||||||
|
CurrentAgent: Profile{
|
||||||
|
Kind: "agent",
|
||||||
|
Name: "Bảo An",
|
||||||
|
Text: "điều phối chung",
|
||||||
|
},
|
||||||
|
SelfTools: []Profile{
|
||||||
|
{Kind: "tool", Name: "self chat", Text: "trả lời trò chuyện nhanh"},
|
||||||
|
},
|
||||||
|
Team: Profile{
|
||||||
|
Kind: "team",
|
||||||
|
Name: "Growth Team",
|
||||||
|
Text: "team content chiến lược chiến dịch",
|
||||||
|
},
|
||||||
|
Members: []Profile{
|
||||||
|
{Kind: "member", Name: "Bảo Ly Content", Text: "content bài viết kịch bản truyền thông"},
|
||||||
|
{Kind: "member", Name: "Bảo Ly Chiến lược", Text: "chiến lược kế hoạch phân tích"},
|
||||||
|
},
|
||||||
|
CollaborationTools: []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "chia việc giao task cho thành viên team"},
|
||||||
|
},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"lập kế hoạch": {1, 0},
|
||||||
|
"content": {1, 0},
|
||||||
|
"chiến lược": {1, 0},
|
||||||
|
"điều phối": {0, 1},
|
||||||
|
"trò chuyện": {0, 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if result.Decision != DecisionTeam {
|
||||||
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionTeam, result)
|
||||||
|
}
|
||||||
|
if result.CollaborationScore <= result.SelfScore {
|
||||||
|
t.Fatalf("CollaborationScore %.3f must be greater than SelfScore %.3f", result.CollaborationScore, result.SelfScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySelfWhenCurrentAgentToolsAreBestMatch(t *testing.T) {
|
||||||
|
result := Classify(context.Background(), Input{
|
||||||
|
Mode: ModeDelegate,
|
||||||
|
Message: "dịch nhanh câu này sang tiếng Anh",
|
||||||
|
CurrentAgent: Profile{
|
||||||
|
Kind: "agent",
|
||||||
|
Name: "Translator",
|
||||||
|
Text: "dịch thuật trả lời ngắn",
|
||||||
|
},
|
||||||
|
SelfTools: []Profile{
|
||||||
|
{Kind: "tool", Name: "translate", Text: "dịch nhanh văn bản"},
|
||||||
|
},
|
||||||
|
Delegates: []Profile{
|
||||||
|
{Kind: "delegate", Name: "Planner", Text: "lập kế hoạch dự án dài"},
|
||||||
|
},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"dịch nhanh": {1, 0},
|
||||||
|
"dịch thuật": {1, 0},
|
||||||
|
"lập kế hoạch": {0, 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDefaultsToSelfWhenScoresAreClose(t *testing.T) {
|
||||||
|
result := Classify(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "phân tích giúp việc này nên xử lý theo cá nhân hay theo team",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"phân tích": {1, 0},
|
||||||
|
"xử lý yêu cầu chung": {1, 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q; scores %.3f %.3f", result.Decision, DecisionSelf, result.SelfScore, result.CollaborationScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySelfForCasualOrWeakCloseMessages(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{name: "casual greeting", message: "chào em"},
|
||||||
|
{name: "weak close scores", message: "anh hỏi thêm chút thôi"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := Classify(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: tc.message,
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "xử lý yêu cầu chung"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "xử lý yêu cầu chung theo team"},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"xử lý yêu cầu chung": {1, 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMUsesLongerDefaultArbiterTimeout(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{content: `{"decision":"self","confidence":0.5,"mode":"team","reason":"direct enough"}`}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
||||||
|
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"lập kế hoạch": {1, 0},
|
||||||
|
"content": {1, 0},
|
||||||
|
"điều phối": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q", result.Decision, DecisionSelf)
|
||||||
|
}
|
||||||
|
if provider.deadlineRemaining < 25*time.Second {
|
||||||
|
t.Fatalf("arbiter deadline remaining = %s, want at least 25s", provider.deadlineRemaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMFallsBackToSelfOnArbiterError(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{err: errors.New("provider unavailable")}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
||||||
|
Members: []Profile{{Kind: "team_member", Name: "Bảo Ly", Text: "content chiến lược"}},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"lập kế hoạch": {1, 0},
|
||||||
|
"content": {1, 0},
|
||||||
|
"điều phối": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want %q; result=%+v", result.Decision, DecisionSelf, result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Reason, "arbiter_failed") {
|
||||||
|
t.Fatalf("Reason = %q, want arbiter_failed marker", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeArbiterProvider struct {
|
||||||
|
content string
|
||||||
|
err error
|
||||||
|
req providers.ChatRequest
|
||||||
|
deadlineRemaining time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeArbiterProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
|
||||||
|
p.req = req
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
p.deadlineRemaining = time.Until(deadline)
|
||||||
|
}
|
||||||
|
if p.err != nil {
|
||||||
|
return nil, p.err
|
||||||
|
}
|
||||||
|
return &providers.ChatResponse{Content: p.content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeArbiterProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeArbiterProvider) DefaultModel() string { return "fake-model" }
|
||||||
|
func (p *fakeArbiterProvider) Name() string { return "fake-provider" }
|
||||||
|
|
||||||
|
func TestParseArbiterResultAcceptsSelfOrTeamOnly(t *testing.T) {
|
||||||
|
team, err := ParseArbiterResult(`{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"needs members"}`, ModeTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseArbiterResult(team) error = %v", err)
|
||||||
|
}
|
||||||
|
if team.Decision != DecisionTeam || team.RequiredTool != "team_tasks" || team.Mode != ModeTeam {
|
||||||
|
t.Fatalf("team result = %+v", team)
|
||||||
|
}
|
||||||
|
|
||||||
|
self, err := ParseArbiterResult(`{"decision":"ask","confidence":0.55,"mode":"team","reason":"unclear"}`, ModeTeam)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseArbiterResult(unsupported decision fallback) error = %v", err)
|
||||||
|
}
|
||||||
|
if self.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("unsupported decision must fall back to self, got %+v", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMUsesArbiterDecisionAndEvidence(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.86,"mode":"team","required_tool":"team_tasks","reason":"requires content and strategy members"}`}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "lập kế hoạch content và chiến lược cho chiến dịch mới",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "điều phối chung"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Growth Team", Text: "content chiến lược"},
|
||||||
|
Members: []Profile{
|
||||||
|
{Kind: "team_member", Name: "Bảo Ly Content", Text: "content"},
|
||||||
|
{Kind: "team_member", Name: "Bảo Ly Chiến lược", Text: "chiến lược"},
|
||||||
|
},
|
||||||
|
CollaborationTools: []Profile{{Kind: "tool", Name: "team_tasks", Text: "assign team work"}},
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"lập kế hoạch": {1, 0},
|
||||||
|
"content": {1, 0},
|
||||||
|
"chiến lược": {1, 0},
|
||||||
|
"điều phối": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
|
||||||
|
t.Fatalf("ClassifyWithLLM result = %+v", result)
|
||||||
|
}
|
||||||
|
if len(provider.req.Messages) < 2 {
|
||||||
|
t.Fatalf("arbiter request messages missing: %+v", provider.req)
|
||||||
|
}
|
||||||
|
joined := provider.req.Messages[0].Content + "\n" + provider.req.Messages[1].Content
|
||||||
|
for _, want := range []string{"Return ONLY JSON", "Bảo An", "Growth Team", "team_tasks", "self_score", "collaboration_score"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("arbiter prompt missing %q:\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMForcesSelfWhenMemberCannotAssignOrRequest(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.91,"mode":"team","required_tool":"team_tasks","reason":"route to teammate"}`}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "nhờ thành viên khác làm giúp phần này",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "shared work"},
|
||||||
|
TeamRole: "member",
|
||||||
|
CanAssignTeamTasks: false,
|
||||||
|
MemberRequestsEnabled: false,
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"nhờ thành viên": {1, 0},
|
||||||
|
"shared work": {1, 0},
|
||||||
|
"team member": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want self for member without request permission; result=%+v", result.Decision, result)
|
||||||
|
}
|
||||||
|
if result.RequiredTool != "" {
|
||||||
|
t.Fatalf("RequiredTool = %q, want empty", result.RequiredTool)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Reason, "member lacks team request permission") {
|
||||||
|
t.Fatalf("Reason = %q, want member permission marker", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMForcesSelfWhenMemberRequestNeedsLeaderReview(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
|
||||||
|
TeamRole: "member",
|
||||||
|
CanAssignTeamTasks: false,
|
||||||
|
MemberRequestsEnabled: true,
|
||||||
|
MemberRequestsAutoDispatch: false,
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"request": {1, 0},
|
||||||
|
"content team": {1, 0},
|
||||||
|
"team member": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionSelf {
|
||||||
|
t.Fatalf("Decision = %q, want self when member request does not auto-dispatch; result=%+v", result.Decision, result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Reason, "member lacks auto-dispatch team request permission") {
|
||||||
|
t.Fatalf("Reason = %q, want auto-dispatch permission marker", result.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWithLLMAllowsAutoDispatchMemberRequest(t *testing.T) {
|
||||||
|
provider := &fakeArbiterProvider{content: `{"decision":"team","confidence":0.88,"mode":"team","required_tool":"team_tasks","reason":"member should request help"}`}
|
||||||
|
result := ClassifyWithLLM(context.Background(), Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "em tạo request nhờ bạn content hỗ trợ phần này",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "team member"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "content team"},
|
||||||
|
TeamRole: "member",
|
||||||
|
CanAssignTeamTasks: false,
|
||||||
|
MemberRequestsEnabled: true,
|
||||||
|
MemberRequestsAutoDispatch: true,
|
||||||
|
Embedder: fakeEmbedder{
|
||||||
|
"request": {1, 0},
|
||||||
|
"content team": {1, 0},
|
||||||
|
"team member": {0, 1},
|
||||||
|
},
|
||||||
|
}, provider, "arbiter-model", nil)
|
||||||
|
if result.Decision != DecisionTeam || result.RequiredTool != "team_tasks" {
|
||||||
|
t.Fatalf("ClassifyWithLLM result = %+v, want team/team_tasks", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.WorkflowHint, `task_type="request"`) {
|
||||||
|
t.Fatalf("WorkflowHint = %q, want task_type=request guidance", result.WorkflowHint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.WorkflowHint, "auto-dispatch") {
|
||||||
|
t.Fatalf("WorkflowHint = %q, want auto-dispatch guidance", result.WorkflowHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArbiterMessagesIncludesStrictTeamRoutingPolicy(t *testing.T) {
|
||||||
|
messages := BuildArbiterMessages(Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "em đọc cả 2 file rồi diễn giải lại cho anh về phương án em chọn",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Bảo An", Text: "lead and synthesize existing team outputs"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Strategy Team", Text: "team research and execution"},
|
||||||
|
CollaborationTools: []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "search existing tasks, create tasks, assign work"},
|
||||||
|
{Kind: "capability", Name: "shared team workspace", Text: "files from previous team work"},
|
||||||
|
},
|
||||||
|
}, Result{
|
||||||
|
Decision: DecisionSelf,
|
||||||
|
SelfScore: 0.6341,
|
||||||
|
CollaborationScore: 0.6362,
|
||||||
|
})
|
||||||
|
if len(messages) < 2 {
|
||||||
|
t.Fatalf("messages = %+v, want system and user messages", messages)
|
||||||
|
}
|
||||||
|
prompt := messages[0].Content + "\n" + messages[1].Content
|
||||||
|
for _, want := range []string{
|
||||||
|
"Choose self when the user directly asks the current agent to read, summarize, explain, compare, or interpret existing files",
|
||||||
|
"Do not choose team only because files are located in the team workspace",
|
||||||
|
"Do not choose team only because the topic mentions team, workflow, strategy, content, or prior team output",
|
||||||
|
"Choose team only when the user explicitly asks to assign, delegate, split work, ask other members, create tasks, gather opinions, or perform new multi-role work",
|
||||||
|
"When self_score and collaboration_score are close, choose self unless there is a clear team signal",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(prompt, want) {
|
||||||
|
t.Fatalf("arbiter prompt missing policy %q:\n%s", want, prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArbiterMessagesIncludesTeamPermissionContext(t *testing.T) {
|
||||||
|
messages := BuildArbiterMessages(Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
Message: "nhờ thành viên khác hỗ trợ phần này",
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Member", Text: "member role"},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team", Text: "team work"},
|
||||||
|
TeamRole: "member",
|
||||||
|
CanAssignTeamTasks: false,
|
||||||
|
MemberRequestsEnabled: true,
|
||||||
|
MemberRequestsAutoDispatch: false,
|
||||||
|
}, Result{Decision: DecisionSelf})
|
||||||
|
prompt := messages[0].Content + "\n" + messages[1].Content
|
||||||
|
for _, want := range []string{
|
||||||
|
"Team permission context",
|
||||||
|
"current_agent_team_role: member",
|
||||||
|
"can_assign_team_tasks: false",
|
||||||
|
"member_requests_enabled: true",
|
||||||
|
"member_requests_auto_dispatch: false",
|
||||||
|
"choose self",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(prompt, want) {
|
||||||
|
t.Fatalf("arbiter prompt missing %q:\n%s", want, prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProfileDocumentsIncludesTeamLinksAndTools(t *testing.T) {
|
||||||
|
input := Input{
|
||||||
|
Mode: ModeTeam,
|
||||||
|
CurrentAgent: Profile{Kind: "agent", Name: "Lead", Text: "lead"},
|
||||||
|
SelfTools: []Profile{{Kind: "tool", Name: "web_search", Text: "search"}},
|
||||||
|
Team: Profile{Kind: "team", Name: "Team A", Text: "team"},
|
||||||
|
Members: []Profile{{Kind: "member", Name: "Member A", Text: "member"}},
|
||||||
|
Delegates: []Profile{{Kind: "delegate", Name: "Delegate A", Text: "delegate"}},
|
||||||
|
CollaborationTools: []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "team task board"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
docs := BuildProfileDocuments(input)
|
||||||
|
joined := strings.Join(docs, "\n")
|
||||||
|
for _, want := range []string{"Lead", "web_search", "Team A", "Member A", "Delegate A", "team_tasks"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("profile documents missing %q:\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package teamworkclassify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileStores struct {
|
||||||
|
Agents store.AgentStore
|
||||||
|
Teams store.TeamStore
|
||||||
|
AgentLinks store.AgentLinkStore
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildInputOptions struct {
|
||||||
|
Mode Mode
|
||||||
|
Message string
|
||||||
|
AgentID uuid.UUID
|
||||||
|
ToolAllow []string
|
||||||
|
SkillFilter []string
|
||||||
|
Embedder Embedder
|
||||||
|
ExtraSelf []Profile
|
||||||
|
ExtraCollab []Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildInputFromStores(ctx context.Context, stores ProfileStores, opts BuildInputOptions) Input {
|
||||||
|
input := Input{
|
||||||
|
Mode: opts.Mode,
|
||||||
|
Message: opts.Message,
|
||||||
|
Embedder: opts.Embedder,
|
||||||
|
}
|
||||||
|
if opts.Mode == "" || opts.Mode == ModeSpawn || opts.AgentID == uuid.Nil {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
if stores.Agents != nil {
|
||||||
|
if ag, err := stores.Agents.GetByID(ctx, opts.AgentID); err == nil && ag != nil {
|
||||||
|
input.CurrentAgent = Profile{
|
||||||
|
Kind: "agent",
|
||||||
|
Name: firstNonEmpty(ag.DisplayName, ag.AgentKey),
|
||||||
|
Text: strings.TrimSpace(strings.Join([]string{
|
||||||
|
ag.Frontmatter,
|
||||||
|
ag.AgentDescription,
|
||||||
|
}, "\n")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.SelfTools = append(input.SelfTools, effectiveSelfToolProfiles(opts.ToolAllow, opts.SkillFilter)...)
|
||||||
|
input.SelfTools = append(input.SelfTools, opts.ExtraSelf...)
|
||||||
|
|
||||||
|
if opts.Mode == ModeTeam && stores.Teams != nil {
|
||||||
|
if team, err := stores.Teams.GetTeamForAgent(ctx, opts.AgentID); err == nil && team != nil {
|
||||||
|
input.TeamRole = "member"
|
||||||
|
if team.LeadAgentID == opts.AgentID {
|
||||||
|
input.TeamRole = "lead"
|
||||||
|
input.CanAssignTeamTasks = true
|
||||||
|
}
|
||||||
|
memberRequestCfg := parseMemberRequestRoutingConfig(team.Settings)
|
||||||
|
input.MemberRequestsEnabled = memberRequestCfg.Enabled
|
||||||
|
input.MemberRequestsAutoDispatch = memberRequestCfg.AutoDispatch
|
||||||
|
input.Team = Profile{
|
||||||
|
Kind: "team",
|
||||||
|
Name: team.Name,
|
||||||
|
Text: strings.TrimSpace(strings.Join([]string{
|
||||||
|
team.Description,
|
||||||
|
fmt.Sprintf("lead_agent: %s %s", team.LeadAgentKey, team.LeadDisplayName),
|
||||||
|
}, "\n")),
|
||||||
|
}
|
||||||
|
if members, err := stores.Teams.ListMembers(ctx, team.ID); err == nil {
|
||||||
|
for _, member := range members {
|
||||||
|
if member.AgentID == opts.AgentID && !input.CanAssignTeamTasks {
|
||||||
|
if strings.TrimSpace(member.Role) != "" {
|
||||||
|
input.TeamRole = member.Role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.Members = append(input.Members, Profile{
|
||||||
|
Kind: "team_member",
|
||||||
|
Name: firstNonEmpty(member.DisplayName, member.AgentKey),
|
||||||
|
Text: strings.TrimSpace(strings.Join([]string{
|
||||||
|
"role: " + member.Role,
|
||||||
|
"agent_key: " + member.AgentKey,
|
||||||
|
member.Frontmatter,
|
||||||
|
}, "\n")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.CollaborationTools = append(input.CollaborationTools, teamPermissionProfiles(input)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stores.AgentLinks != nil {
|
||||||
|
if links, err := stores.AgentLinks.DelegateTargets(ctx, opts.AgentID); err == nil {
|
||||||
|
for _, link := range links {
|
||||||
|
input.Delegates = append(input.Delegates, Profile{
|
||||||
|
Kind: "delegate",
|
||||||
|
Name: firstNonEmpty(link.TargetDisplayName, link.TargetAgentKey),
|
||||||
|
Text: strings.TrimSpace(strings.Join([]string{
|
||||||
|
"agent_key: " + link.TargetAgentKey,
|
||||||
|
"link_direction: " + link.Direction,
|
||||||
|
"link_team: " + link.TeamName,
|
||||||
|
"target_team: " + link.TargetTeamName,
|
||||||
|
fmt.Sprintf("target_is_team_lead: %t", link.TargetIsTeamLead),
|
||||||
|
link.Description,
|
||||||
|
link.TargetDescription,
|
||||||
|
}, "\n")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if opts.Mode == ModeDelegate && len(links) > 0 {
|
||||||
|
input.CollaborationTools = append(input.CollaborationTools,
|
||||||
|
Profile{Kind: "tool", Name: "delegate", Text: "delegate work to linked agents with matching expertise and receive their result"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.CollaborationTools = append(input.CollaborationTools, opts.ExtraCollab...)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
type memberRequestRoutingConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
AutoDispatch bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMemberRequestRoutingConfig(settings json.RawMessage) memberRequestRoutingConfig {
|
||||||
|
var cfg memberRequestRoutingConfig
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
MemberRequests *struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
AutoDispatch *bool `json:"auto_dispatch"`
|
||||||
|
} `json:"member_requests"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(settings, &raw) != nil || raw.MemberRequests == nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if raw.MemberRequests.Enabled != nil {
|
||||||
|
cfg.Enabled = *raw.MemberRequests.Enabled
|
||||||
|
}
|
||||||
|
if raw.MemberRequests.AutoDispatch != nil {
|
||||||
|
cfg.AutoDispatch = *raw.MemberRequests.AutoDispatch
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamPermissionProfiles(input Input) []Profile {
|
||||||
|
role := strings.ToLower(strings.TrimSpace(input.TeamRole))
|
||||||
|
if role == "" || role == "lead" || input.CanAssignTeamTasks {
|
||||||
|
return []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "lead can search existing tasks, create tasks, assign work to team members, track progress, review and complete team work"},
|
||||||
|
{Kind: "tool", Name: "ask_user", Text: "ask the user for decisions or missing information during team work"},
|
||||||
|
{Kind: "capability", Name: "shared team workspace", Text: "coordinate multi-step work through shared files, task board, and team member results"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.MemberRequestsEnabled && input.MemberRequestsAutoDispatch {
|
||||||
|
return []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: `member cannot assign general tasks; member may create task_type="request" to ask another teammate for help; requests auto-dispatch to the assignee`},
|
||||||
|
{Kind: "capability", Name: "member request workflow", Text: "request help from teammates without lead-style assignment authority"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.MemberRequestsEnabled {
|
||||||
|
return []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "member request tasks are enabled but auto-dispatch is disabled; requests stay pending for leader review and should not be used as immediate routed workflow"},
|
||||||
|
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []Profile{
|
||||||
|
{Kind: "tool", Name: "team_tasks", Text: "member cannot create or assign general tasks; member request tasks are disabled; use comments/progress/current-task actions only"},
|
||||||
|
{Kind: "capability", Name: "member limited team access", Text: "cannot coordinate new team work without the lead"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveSelfToolProfiles(toolAllow, skillFilter []string) []Profile {
|
||||||
|
var profiles []Profile
|
||||||
|
if len(toolAllow) > 0 {
|
||||||
|
profiles = append(profiles, Profile{
|
||||||
|
Kind: "tool_allow",
|
||||||
|
Name: "channel allowed tools",
|
||||||
|
Text: "effective channel/group allowed tools: " + strings.Join(toolAllow, ", "),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
profiles = append(profiles, Profile{
|
||||||
|
Kind: "tool_allow",
|
||||||
|
Name: "default tools",
|
||||||
|
Text: "current agent may use its configured tools for direct small tasks",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(skillFilter) > 0 {
|
||||||
|
profiles = append(profiles, Profile{
|
||||||
|
Kind: "skill_filter",
|
||||||
|
Name: "topic skills",
|
||||||
|
Text: "effective topic skill filter: " + strings.Join(skillFilter, ", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ export interface InitState {
|
|||||||
embMaxChunkLen: string;
|
embMaxChunkLen: string;
|
||||||
embChunkOverlap: string;
|
embChunkOverlap: string;
|
||||||
intentClassify: boolean;
|
intentClassify: boolean;
|
||||||
|
teamWorkClassify: boolean;
|
||||||
compProvider: string;
|
compProvider: string;
|
||||||
compModel: string;
|
compModel: string;
|
||||||
compThreshold: string;
|
compThreshold: string;
|
||||||
@@ -53,6 +54,7 @@ export const DEFAULTS: InitState = {
|
|||||||
embProvider: "", embModel: "",
|
embProvider: "", embModel: "",
|
||||||
embMaxChunkLen: "", embChunkOverlap: "",
|
embMaxChunkLen: "", embChunkOverlap: "",
|
||||||
intentClassify: true,
|
intentClassify: true,
|
||||||
|
teamWorkClassify: false,
|
||||||
compProvider: "", compModel: "",
|
compProvider: "", compModel: "",
|
||||||
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
||||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Link } from "react-router";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -42,6 +42,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
|||||||
|
|
||||||
// UX Behavior
|
// UX Behavior
|
||||||
const [intentClassify, setIntentClassify] = useState(true);
|
const [intentClassify, setIntentClassify] = useState(true);
|
||||||
|
const [teamWorkClassify, setTeamWorkClassify] = useState(false);
|
||||||
|
|
||||||
// Compaction
|
// Compaction
|
||||||
const [compProvider, setCompProvider] = useState("");
|
const [compProvider, setCompProvider] = useState("");
|
||||||
@@ -72,6 +73,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
|||||||
embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "",
|
embProvider: configs["embedding.provider"] ?? "", embModel: configs["embedding.model"] ?? "",
|
||||||
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
|
embMaxChunkLen: configs["embedding.max_chunk_len"] ?? "", embChunkOverlap: configs["embedding.chunk_overlap"] ?? "",
|
||||||
intentClassify: parseBool(configs["gateway.intent_classify"], true),
|
intentClassify: parseBool(configs["gateway.intent_classify"], true),
|
||||||
|
teamWorkClassify: parseBool(configs["gateway.team_work_classify"], false),
|
||||||
compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "",
|
compProvider: configs["compaction.provider"] ?? "", compModel: configs["compaction.model"] ?? "",
|
||||||
compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "",
|
compThreshold: configs["compaction.threshold"] ?? "", compKeepRecent: configs["compaction.keep_recent"] ?? "",
|
||||||
compMaxTokens: configs["compaction.max_tokens"] ?? "",
|
compMaxTokens: configs["compaction.max_tokens"] ?? "",
|
||||||
@@ -87,6 +89,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
|||||||
setInit(s);
|
setInit(s);
|
||||||
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
||||||
setIntentClassify(s.intentClassify);
|
setIntentClassify(s.intentClassify);
|
||||||
|
setTeamWorkClassify(s.teamWorkClassify);
|
||||||
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
|
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
|
||||||
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
||||||
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
|
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 (embMaxChunkLen !== init.embMaxChunkLen) updates["embedding.max_chunk_len"] = embMaxChunkLen;
|
||||||
if (embChunkOverlap !== init.embChunkOverlap) updates["embedding.chunk_overlap"] = embChunkOverlap;
|
if (embChunkOverlap !== init.embChunkOverlap) updates["embedding.chunk_overlap"] = embChunkOverlap;
|
||||||
if (intentClassify !== init.intentClassify) updates["gateway.intent_classify"] = String(intentClassify);
|
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 (compProvider !== init.compProvider) updates["compaction.provider"] = compProvider;
|
||||||
if (compModel !== init.compModel) updates["compaction.model"] = compModel;
|
if (compModel !== init.compModel) updates["compaction.model"] = compModel;
|
||||||
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
|
if (compThreshold !== init.compThreshold) updates["compaction.threshold"] = compThreshold;
|
||||||
@@ -155,6 +159,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
|||||||
|
|
||||||
const uxItems: FeatureSwitchItem[] = [
|
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: 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 (
|
return (
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface FeatureSwitchItem {
|
|||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (v: boolean) => void;
|
onCheckedChange: (v: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledHint?: string;
|
||||||
/** Contextual info message shown when the toggle is ON */
|
/** Contextual info message shown when the toggle is ON */
|
||||||
infoWhenOn?: string;
|
infoWhenOn?: string;
|
||||||
/** Accent classes for the info box (border + bg + text + dark variants) */
|
/** Accent classes for the info box (border + bg + text + dark variants) */
|
||||||
@@ -80,12 +82,20 @@ export function FeatureSwitchGroup({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onCheckedChange={item.onCheckedChange}
|
onCheckedChange={item.onCheckedChange}
|
||||||
|
disabled={item.disabled}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Conditional info box when enabled */}
|
||||||
{item.checked && item.infoWhenOn && (
|
{item.checked && !item.disabled && item.infoWhenOn && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"mt-3 flex items-start gap-2 rounded-md border px-3 py-2 text-xs",
|
"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",
|
item.infoClass ?? "border-primary/20 bg-primary/5 text-primary dark:bg-primary/10",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
"agents.workspaceTip": "Root directory for agent file operations. Per-user subdirectories are created automatically.",
|
"agents.workspaceTip": "Root directory for agent file operations. Per-user subdirectories are created automatically.",
|
||||||
"agents.restrictToWorkspace": "Restrict to Workspace",
|
"agents.restrictToWorkspace": "Restrict to Workspace",
|
||||||
"agents.restrictToWorkspaceTip": "Prevent agents from reading or writing files outside the workspace path.",
|
"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.saveError": "Failed to save agent defaults",
|
||||||
|
|
||||||
"agents.subagents.title": "Subagents",
|
"agents.subagents.title": "Subagents",
|
||||||
@@ -122,8 +122,12 @@
|
|||||||
|
|
||||||
"behavior.uxTitle": "UX Behavior",
|
"behavior.uxTitle": "UX Behavior",
|
||||||
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
|
"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.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": "Agent will only be invoked when the classifier detects actionable intent.",
|
"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.chatTitle": "Human-like Channel Delivery",
|
||||||
"behavior.chatDescription": "Sidecar acknowledgement, intermediate progress, and safe final reply splitting for non-streaming channels",
|
"behavior.chatDescription": "Sidecar acknowledgement, intermediate progress, and safe final reply splitting for non-streaming channels",
|
||||||
"behavior.chatEnabled": "Enable channel delivery behavior",
|
"behavior.chatEnabled": "Enable channel delivery behavior",
|
||||||
|
|||||||
@@ -30,9 +30,13 @@
|
|||||||
"ux": {
|
"ux": {
|
||||||
"title": "UX Behavior",
|
"title": "UX Behavior",
|
||||||
"description": "Control how the gateway displays agent activity to users.",
|
"description": "Control how the gateway displays agent activity to users.",
|
||||||
"intentClassify": "Intent classification",
|
"intentClassify": "Busy agent message classification",
|
||||||
"intentClassifyHint": "Classify user intent before routing to the agent.",
|
"intentClassifyHint": "When the agent is already processing a previous turn, classify new messages as status questions, cancel requests, steering instructions, or new requests.",
|
||||||
"intentClassifyInfo": "Messages are classified to optimize agent routing and behavior."
|
"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": {
|
"kg": {
|
||||||
"title": "Knowledge Graph",
|
"title": "Knowledge Graph",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
"agents.workspaceTip": "에이전트 파일 작업의 루트 디렉터리입니다. 사용자별 하위 디렉터리가 자동으로 생성됩니다.",
|
"agents.workspaceTip": "에이전트 파일 작업의 루트 디렉터리입니다. 사용자별 하위 디렉터리가 자동으로 생성됩니다.",
|
||||||
"agents.restrictToWorkspace": "워크스페이스로 제한",
|
"agents.restrictToWorkspace": "워크스페이스로 제한",
|
||||||
"agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.",
|
"agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.",
|
||||||
"agents.intentClassify": "의도 분류",
|
"agents.intentClassify": "에이전트 작업 중 메시지 분류",
|
||||||
"agents.saveError": "에이전트 기본값 저장 실패",
|
"agents.saveError": "에이전트 기본값 저장 실패",
|
||||||
|
|
||||||
"agents.subagents.title": "서브에이전트",
|
"agents.subagents.title": "서브에이전트",
|
||||||
@@ -126,8 +126,12 @@
|
|||||||
"behavior.toolStatusInfo": "에이전트가 파일을 읽고, 명령을 실행하고, API를 호출하는 동안 사용자에게 라이브 업데이트가 표시됩니다.",
|
"behavior.toolStatusInfo": "에이전트가 파일을 읽고, 명령을 실행하고, API를 호출하는 동안 사용자에게 라이브 업데이트가 표시됩니다.",
|
||||||
"behavior.blockReplyHint": "도구가 호출되는 동안 채널에 부분 어시스턴트 텍스트를 전송합니다.",
|
"behavior.blockReplyHint": "도구가 호출되는 동안 채널에 부분 어시스턴트 텍스트를 전송합니다.",
|
||||||
"behavior.blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 전달됩니다. 최종 응답만이 아닙니다.",
|
"behavior.blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 전달됩니다. 최종 응답만이 아닙니다.",
|
||||||
"behavior.intentClassifyHint": "불필요한 에이전트 호출을 줄이기 위해 라우팅 전에 사용자 의도를 분류합니다.",
|
"behavior.intentClassifyHint": "에이전트가 이전 요청을 처리 중일 때 새 메시지를 상태 질문, 취소, 추가 지시, 새 요청으로 분류합니다.",
|
||||||
"behavior.intentClassifyInfo": "에이전트는 분류기가 실행 가능한 의도를 감지할 때만 호출됩니다.",
|
"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.pendingCompactionTitle": "대기 메시지 압축",
|
||||||
"behavior.pendingCompactionDescription": "버퍼가 임계값을 초과할 때 LLM을 사용하여 이전 그룹 메시지를 자동으로 요약합니다",
|
"behavior.pendingCompactionDescription": "버퍼가 임계값을 초과할 때 LLM을 사용하여 이전 그룹 메시지를 자동으로 요약합니다",
|
||||||
"behavior.pendingCompactionThreshold": "임계값",
|
"behavior.pendingCompactionThreshold": "임계값",
|
||||||
|
|||||||
@@ -36,9 +36,13 @@
|
|||||||
"blockReply": "도구 호출 중 중간 텍스트 표시",
|
"blockReply": "도구 호출 중 중간 텍스트 표시",
|
||||||
"blockReplyHint": "도구 호출 중 부분 어시스턴트 텍스트를 스트리밍합니다.",
|
"blockReplyHint": "도구 호출 중 부분 어시스턴트 텍스트를 스트리밍합니다.",
|
||||||
"blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 스트리밍됩니다.",
|
"blockReplyInfo": "도구 실행 중 사용자에게 중간 텍스트가 스트리밍됩니다.",
|
||||||
"intentClassify": "의도 분류",
|
"intentClassify": "에이전트 작업 중 메시지 분류",
|
||||||
"intentClassifyHint": "에이전트로 라우팅하기 전에 사용자 의도를 분류합니다.",
|
"intentClassifyHint": "에이전트가 이전 요청을 처리 중일 때 새 메시지를 상태 질문, 취소, 추가 지시, 새 요청으로 분류합니다.",
|
||||||
"intentClassifyInfo": "에이전트 라우팅 및 동작 최적화를 위해 메시지가 분류됩니다."
|
"intentClassifyInfo": "이 기능은 세션이 작업 중일 때만 적용되며, 요청에 팀 워크플로가 필요한지는 판단하지 않습니다.",
|
||||||
|
"teamWorkClassify": "Team Work 분류",
|
||||||
|
"teamWorkClassifyHint": "새 요청을 직접 처리 또는 team/delegate 워크플로로 분류합니다.",
|
||||||
|
"teamWorkClassifyInfo": "요청이 현재 에이전트의 범위를 넘거나 분담이 필요하면 시스템이 embedding을 근거로 사용하고 모델이 직접 처리와 team/delegate 워크플로 중 하나를 결정하게 합니다. 시스템은 lead/member 권한도 확인합니다. member는 팀에서 member request와 auto-dispatch를 모두 켠 경우에만 도움 요청을 만들 수 있습니다.",
|
||||||
|
"teamWorkClassifyEmbeddingRequired": "Team Work 분류를 켜려면 먼저 embedding을 설정해야 합니다."
|
||||||
},
|
},
|
||||||
"kg": {
|
"kg": {
|
||||||
"title": "지식 그래프",
|
"title": "지식 그래프",
|
||||||
|
|||||||
@@ -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.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.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.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.saveError": "Lưu mặc định agent thất bại",
|
||||||
|
|
||||||
"agents.subagents.title": "Subagent",
|
"agents.subagents.title": "Subagent",
|
||||||
@@ -122,8 +122,12 @@
|
|||||||
|
|
||||||
"behavior.uxTitle": "Hành vi giao diện",
|
"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.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.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": "Agent chỉ được gọi khi bộ phân loại phát hiện ý định có thể thực hiện.",
|
"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.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.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",
|
"behavior.chatEnabled": "Bật hành vi gửi tin theo kênh",
|
||||||
|
|||||||
@@ -30,9 +30,13 @@
|
|||||||
"ux": {
|
"ux": {
|
||||||
"title": "Hành vi 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.",
|
"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",
|
"intentClassify": "Phân loại tin nhắn khi agent đang bận",
|
||||||
"intentClassifyHint": "Phân loại ý định người dùng trước khi chuyển tới agent.",
|
"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": "Tin nhắn được phân loại để tối ưu định tuyến agent."
|
"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": {
|
"kg": {
|
||||||
"title": "Đồ thị tri thức",
|
"title": "Đồ thị tri thức",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
"agents.workspaceTip": "Agent 文件操作的根目录,运行时自动创建每用户子目录。",
|
"agents.workspaceTip": "Agent 文件操作的根目录,运行时自动创建每用户子目录。",
|
||||||
"agents.restrictToWorkspace": "限制在工作区内",
|
"agents.restrictToWorkspace": "限制在工作区内",
|
||||||
"agents.restrictToWorkspaceTip": "阻止 Agent 读写工作区路径以外的文件。",
|
"agents.restrictToWorkspaceTip": "阻止 Agent 读写工作区路径以外的文件。",
|
||||||
"agents.intentClassify": "意图分类",
|
"agents.intentClassify": "Agent 忙碌时的消息分类",
|
||||||
"agents.saveError": "保存 Agent 默认值失败",
|
"agents.saveError": "保存 Agent 默认值失败",
|
||||||
|
|
||||||
"agents.subagents.title": "子 Agent",
|
"agents.subagents.title": "子 Agent",
|
||||||
@@ -122,8 +122,12 @@
|
|||||||
|
|
||||||
"behavior.uxTitle": "界面行为",
|
"behavior.uxTitle": "界面行为",
|
||||||
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
|
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
|
||||||
"behavior.intentClassifyHint": "在路由之前对用户意图进行分类,以减少不必要的 Agent 调用。",
|
"behavior.intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
|
||||||
"behavior.intentClassifyInfo": "仅当分类器检测到可执行意图时才调用 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.chatTitle": "拟人化频道投递",
|
||||||
"behavior.chatDescription": "为非流式频道提供 sidecar 确认、中间进度和安全的最终回复拆分",
|
"behavior.chatDescription": "为非流式频道提供 sidecar 确认、中间进度和安全的最终回复拆分",
|
||||||
"behavior.chatEnabled": "启用频道投递行为",
|
"behavior.chatEnabled": "启用频道投递行为",
|
||||||
|
|||||||
@@ -30,9 +30,13 @@
|
|||||||
"ux": {
|
"ux": {
|
||||||
"title": "UX行为",
|
"title": "UX行为",
|
||||||
"description": "控制网关如何向用户展示Agent活动。",
|
"description": "控制网关如何向用户展示Agent活动。",
|
||||||
"intentClassify": "意图分类",
|
"intentClassify": "Agent 忙碌时的消息分类",
|
||||||
"intentClassifyHint": "在路由到Agent之前分类用户意图。",
|
"intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
|
||||||
"intentClassifyInfo": "消息被分类以优化Agent路由和行为。"
|
"intentClassifyInfo": "此功能只在会话忙碌时生效;它不会判断请求是否需要团队工作流。",
|
||||||
|
"teamWorkClassify": "Team Work 分类",
|
||||||
|
"teamWorkClassifyHint": "将新请求分类为自行处理或 team/delegate 工作流。",
|
||||||
|
"teamWorkClassifyInfo": "当请求超出当前 Agent 的职责范围或适合拆分协作时,系统使用 embedding 作为证据,并让模型决定自行处理还是使用 team/delegate 工作流。系统还会检查 lead/member 权限;member 只有在团队启用成员请求并开启自动分配时才能创建求助 request。",
|
||||||
|
"teamWorkClassifyEmbeddingRequired": "需要先配置 embedding 才能启用 Team Work 分类。"
|
||||||
},
|
},
|
||||||
"kg": {
|
"kg": {
|
||||||
"title": "知识图谱",
|
"title": "知识图谱",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
|
|||||||
// UX toggles (from gateway + agents.defaults)
|
// UX toggles (from gateway + agents.defaults)
|
||||||
const [ux, setUx] = useState({
|
const [ux, setUx] = useState({
|
||||||
intent_classify: ag.intent_classify !== false,
|
intent_classify: ag.intent_classify !== false,
|
||||||
|
team_work_classify: gw.team_work_classify === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate limiting (from gateway)
|
// Rate limiting (from gateway)
|
||||||
@@ -62,6 +63,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUx({
|
setUx({
|
||||||
intent_classify: ag.intent_classify !== false,
|
intent_classify: ag.intent_classify !== false,
|
||||||
|
team_work_classify: gw.team_work_classify === true,
|
||||||
});
|
});
|
||||||
setRate({
|
setRate({
|
||||||
max_message_chars: gw.max_message_chars,
|
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,
|
inbound_debounce_ms: rate.inbound_debounce_ms,
|
||||||
injection_action: security.injection_action,
|
injection_action: security.injection_action,
|
||||||
chat_behavior: chatBehavior,
|
chat_behavior: chatBehavior,
|
||||||
|
team_work_classify: ux.team_work_classify,
|
||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
defaults: { intent_classify: ux.intent_classify },
|
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 { useTranslation } from "react-i18next";
|
||||||
import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group";
|
import { FeatureSwitchGroup } from "@/components/shared/feature-switch-group";
|
||||||
import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group";
|
import type { FeatureSwitchItem } from "@/components/shared/feature-switch-group";
|
||||||
|
import { useEmbeddingStatus } from "@/hooks/use-embedding-status";
|
||||||
|
|
||||||
interface UxValues {
|
interface UxValues {
|
||||||
intent_classify: boolean;
|
intent_classify: boolean;
|
||||||
|
team_work_classify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,6 +17,8 @@ interface Props {
|
|||||||
/** High-impact UX toggles with icon, hint, and contextual info. */
|
/** High-impact UX toggles with icon, hint, and contextual info. */
|
||||||
export function BehaviorUxCard({ value, onChange }: Props) {
|
export function BehaviorUxCard({ value, onChange }: Props) {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
|
const { status } = useEmbeddingStatus();
|
||||||
|
const embeddingConfigured = status?.configured === true;
|
||||||
|
|
||||||
const items: FeatureSwitchItem[] = [
|
const items: FeatureSwitchItem[] = [
|
||||||
{
|
{
|
||||||
@@ -27,6 +31,18 @@ export function BehaviorUxCard({ value, onChange }: Props) {
|
|||||||
infoWhenOn: t("behavior.intentClassifyInfo"),
|
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",
|
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 (
|
return (
|
||||||
|
|||||||
Reference in new issue
Block a user