merge: resolve API Route provider conflicts with dev

This commit is contained in:
DennyHo0917 committed 2026-09-07 20:49:55 +08:00
commit eeee2daa5e
1379 files changed
+142297 -9536

No files matched your search

+3 -5
View File
@@ -15,7 +15,6 @@ import (
func agentChatCmd() *cobra.Command {
var (
agentName string
userID string
message string
sessionKey string
)
@@ -31,19 +30,18 @@ Examples:
goclaw agent chat -m "What time is it?" # One-shot message
goclaw agent chat -s my-session # Continue a session`,
Run: func(cmd *cobra.Command, args []string) {
runAgentChat(agentName, message, sessionKey, userID)
runAgentChat(agentName, message, sessionKey)
},
}
cmd.Flags().StringVarP(&agentName, "name", "n", "default", "agent name")
cmd.Flags().StringVarP(&message, "message", "m", "", "one-shot message (omit for interactive mode)")
cmd.Flags().StringVarP(&sessionKey, "session", "s", "", "session key (default: auto-generated)")
cmd.Flags().StringVarP(&userID, "user", "u", "", "user ID for authentication")
return cmd
}
func runAgentChat(agentName, message, sessionKey, userID string) {
func runAgentChat(agentName, message, sessionKey string) {
cfgPath := resolveConfigPath()
cfg, err := config.Load(cfgPath)
if err != nil {
@@ -70,7 +68,7 @@ func runAgentChat(agentName, message, sessionKey, userID string) {
}
fmt.Fprintf(os.Stderr, "Connected to gateway at %s\n", addr)
runClientMode(cfg, addr, agentName, message, sessionKey, userID)
runClientMode(cfg, addr, agentName, message, sessionKey)
}
// --- Gateway detection ---
+7 -6
View File
@@ -15,7 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey, userID string) {
func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey string) {
wsURL := fmt.Sprintf("ws://%s/ws", addr)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
@@ -26,7 +26,7 @@ func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey, use
defer conn.Close()
// Authenticate
if err := wsConnect(conn, cfg.Gateway.Token, userID); err != nil {
if err := wsConnect(conn, cfg.Gateway.Token); err != nil {
fmt.Fprintf(os.Stderr, "Gateway auth failed: %v\n", err)
os.Exit(1)
}
@@ -76,10 +76,14 @@ func runClientMode(cfg *config.Config, addr, agentName, message, sessionKey, use
}
fmt.Printf("\n%s\n\n", resp)
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Input error: %v\n", err)
os.Exit(1)
}
}
// wsConnect sends the connect RPC and waits for auth response.
func wsConnect(conn *websocket.Conn, token, userID string) error {
func wsConnect(conn *websocket.Conn, token string) error {
params := map[string]string{}
userId := os.Getenv("GOCLAW_USER_ID")
if userId == "" { userId = "system" }
@@ -87,9 +91,6 @@ func wsConnect(conn *websocket.Conn, token, userID string) error {
if token != "" {
params["token"] = token
}
if userID != "" {
params["user_id"] = userID
}
paramsJSON, _ := json.Marshal(params)
reqFrame := protocol.RequestFrame{
+152
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
@@ -19,11 +20,130 @@ func cronCmd() *cobra.Command {
Short: "Manage scheduled cron jobs",
}
cmd.AddCommand(cronListCmd())
cmd.AddCommand(cronCreateCmd())
cmd.AddCommand(cronDeleteCmd())
cmd.AddCommand(cronToggleCmd())
return cmd
}
func cronCreateCmd() *cobra.Command {
var (
name string
cronExpr string
every string
at string
tz string
command string
argvJSON string
cwd string
timeout string
envPairs []string
deliver bool
channel string
to string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a deterministic command cron job (runs a shell command, no LLM)",
Long: "Create a cron job whose payload is a shell command executed in the gateway\n" +
"process WITHOUT an LLM turn (zero model tokens). Requires the gateway to have\n" +
"cron.command_enabled=true.\n\n" +
"Examples:\n" +
" goclaw cron create --name disk-probe --cron '*/15 * * * *' --command 'df -h /'\n" +
" goclaw cron create --name backup --at 2026-07-01T09:00:00Z --argv '[\"/opt/backup.sh\"]' --timeout 5m",
Run: func(cmd *cobra.Command, args []string) {
if name == "" {
fmt.Fprintln(os.Stderr, "Error: --name is required")
os.Exit(1)
}
schedule := map[string]any{}
switch {
case cronExpr != "":
schedule["kind"] = "cron"
schedule["expr"] = cronExpr
if tz != "" {
schedule["tz"] = tz
}
case every != "":
d, err := time.ParseDuration(every)
if err != nil || d <= 0 {
fmt.Fprintf(os.Stderr, "Error: invalid --every duration %q\n", every)
os.Exit(1)
}
schedule["kind"] = "every"
schedule["everyMs"] = d.Milliseconds()
case at != "":
ts, err := time.Parse(time.RFC3339, at)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --at time %q (use RFC3339, e.g. 2026-07-01T09:00:00Z)\n", at)
os.Exit(1)
}
schedule["kind"] = "at"
schedule["atMs"] = ts.UnixMilli()
default:
fmt.Fprintln(os.Stderr, "Error: one of --cron, --every, or --at is required")
os.Exit(1)
}
var argv []string
switch {
case argvJSON != "":
if err := json.Unmarshal([]byte(argvJSON), &argv); err != nil {
fmt.Fprintf(os.Stderr, "Error: --argv must be a JSON array of strings: %v\n", err)
os.Exit(1)
}
case command != "":
argv = []string{"sh", "-c", command}
default:
fmt.Fprintln(os.Stderr, "Error: one of --command or --argv is required")
os.Exit(1)
}
commandSpec := map[string]any{"argv": argv}
if cwd != "" {
commandSpec["cwd"] = cwd
}
if timeout != "" {
d, err := time.ParseDuration(timeout)
if err != nil || d <= 0 {
fmt.Fprintf(os.Stderr, "Error: invalid --timeout duration %q\n", timeout)
os.Exit(1)
}
commandSpec["timeoutSeconds"] = int(d.Seconds())
}
if len(envPairs) > 0 {
env := map[string]string{}
for _, kv := range envPairs {
k, v, ok := strings.Cut(kv, "=")
if !ok {
fmt.Fprintf(os.Stderr, "Error: --env must be KEY=VALUE, got %q\n", kv)
os.Exit(1)
}
env[k] = v
}
commandSpec["env"] = env
}
cronCreateCommandRPC(name, schedule, commandSpec, deliver, channel, to)
},
}
cmd.Flags().StringVar(&name, "name", "", "job name (lowercase slug, required)")
cmd.Flags().StringVar(&cronExpr, "cron", "", "cron expression (5-field), e.g. '*/15 * * * *'")
cmd.Flags().StringVar(&every, "every", "", "fixed interval as a Go duration, e.g. 15m")
cmd.Flags().StringVar(&at, "at", "", "one-shot time (RFC3339), e.g. 2026-07-01T09:00:00Z")
cmd.Flags().StringVar(&tz, "tz", "", "IANA timezone for --cron (e.g. Asia/Seoul)")
cmd.Flags().StringVar(&command, "command", "", "shell command (run as sh -c)")
cmd.Flags().StringVar(&argvJSON, "argv", "", `explicit argv as a JSON array, e.g. '["node","x.js"]'`)
cmd.Flags().StringVar(&cwd, "cwd", "", "working directory")
cmd.Flags().StringVar(&timeout, "timeout", "", "per-command timeout as a Go duration, e.g. 30s")
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment override KEY=VALUE (repeatable)")
cmd.Flags().BoolVar(&deliver, "deliver", false, "deliver command output to a channel")
cmd.Flags().StringVar(&channel, "channel", "", "delivery channel")
cmd.Flags().StringVar(&to, "to", "", "delivery chat/target ID")
return cmd
}
func cronListCmd() *cobra.Command {
var jsonOutput bool
var showDisabled bool
@@ -90,6 +210,38 @@ func cronListRPC(showDisabled, jsonOutput bool) {
printCronJobs(result.Jobs, jsonOutput)
}
func cronCreateCommandRPC(name string, schedule, command map[string]any, deliver bool, channel, to string) {
requireGateway()
params, _ := json.Marshal(map[string]any{
"name": name,
"schedule": schedule,
"command": command,
"deliver": deliver,
"deliverChannel": channel,
"deliverTo": to,
})
resp, err := gatewayRPC(protocol.MethodCronCreate, params)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !resp.OK {
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
os.Exit(1)
}
raw, _ := json.Marshal(resp.Payload)
var result struct {
Job store.CronJob `json:"job"`
}
if err := json.Unmarshal(raw, &result); err == nil && result.Job.ID != "" {
fmt.Printf("Created command cron job %s (%s)\n", result.Job.ID, result.Job.Name)
return
}
fmt.Println("Created command cron job.")
}
func cronDeleteRPC(jobID string) {
requireGateway()
+8
View File
@@ -189,6 +189,10 @@ func checkDBChannels(db *sql.DB) {
label := fmt.Sprintf("%s/%s", channelType, name)
fmt.Printf(" %-24s %s\n", label+":", status)
}
if err := rows.Err(); err != nil {
fmt.Printf(" (could not read channels: %s)\n", err)
return
}
if !found {
fmt.Println(" (none configured in database)")
}
@@ -220,6 +224,10 @@ func checkDBProviders(db *sql.DB) {
}
fmt.Printf(" %-16s %s\n", displayName+":", status)
}
if err := rows.Err(); err != nil {
fmt.Printf(" (could not read providers: %s)\n", err)
return
}
if !found {
fmt.Println(" (none configured in database)")
}
+391 -11
View File
@@ -41,13 +41,18 @@ import (
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
mcpoauth "github.com/nextlevelbuilder/goclaw/internal/mcp/oauth"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
"github.com/nextlevelbuilder/goclaw/internal/tools"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
usagepricing "github.com/nextlevelbuilder/goclaw/internal/usage/pricing"
"github.com/nextlevelbuilder/goclaw/internal/vault"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -74,6 +79,123 @@ func gatewayLogOutput() io.Writer {
return io.MultiWriter(os.Stdout, f)
}
type traceCostBackfiller interface {
BackfillLLMCosts(context.Context) (store.TraceCostBackfillStats, error)
}
type traceUsageAggregateReconciler interface {
ReconcileTraceUsageAggregates(context.Context) (store.TraceUsageAggregateStats, error)
}
type usageEventCostBackfiller interface {
BackfillUsageEventCosts(context.Context) (store.UsageEventCostBackfillStats, error)
}
type snapshotCostBackfiller interface {
BackfillSnapshotCosts(context.Context) (store.SnapshotCostBackfillStats, error)
}
type snapshotBucketRefresher interface {
RefreshBuckets(context.Context, []time.Time) (int, error)
}
func recoverInterruptedSubagentTasks(
ctx context.Context,
stores *store.Stores,
retryDelay time.Duration,
) (int64, error) {
recoveryCtx := store.WithTenantID(ctx, store.MasterTenantID)
for {
recovered, err := stores.SubagentTaskRecovery.RecoverInterrupted(recoveryCtx)
if err == nil {
return recovered, nil
}
slog.Warn("subagent_tasks.recover_interrupted_retrying", "err", err)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return 0, ctx.Err()
case <-timer.C:
}
}
}
func backfillTraceCostsAfterPricingSync(ctx context.Context, stores *store.Stores, snapshots snapshotBucketRefresher) {
if stores == nil {
return
}
backfillCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if stores.Tracing != nil {
backfiller, ok := stores.Tracing.(traceCostBackfiller)
if ok {
stats, err := backfiller.BackfillLLMCosts(backfillCtx)
if err != nil {
slog.Warn("usage_pricing.trace_cost_backfill_failed", "error", err)
} else {
refreshedBuckets := 0
if snapshots != nil && len(stats.SnapshotBuckets) > 0 {
refreshedBuckets, err = snapshots.RefreshBuckets(backfillCtx, stats.SnapshotBuckets)
if err != nil {
slog.Warn("usage_pricing.trace_cost_snapshot_refresh_failed", "error", err, "buckets", len(stats.SnapshotBuckets))
}
}
if stats.SpanRowsUpdated > 0 || stats.TraceRowsUpdated > 0 || refreshedBuckets > 0 {
slog.Info("usage_pricing.trace_cost_backfill_complete",
"spans", stats.SpanRowsUpdated,
"traces", stats.TraceRowsUpdated,
"snapshot_buckets", refreshedBuckets,
)
}
}
}
}
if stores.Tracing != nil {
reconciler, ok := stores.Tracing.(traceUsageAggregateReconciler)
if ok {
stats, err := reconciler.ReconcileTraceUsageAggregates(backfillCtx)
if err != nil {
slog.Warn("usage_pricing.trace_usage_aggregate_reconcile_failed", "error", err)
} else if stats.TraceRowsUpdated > 0 {
slog.Info("usage_pricing.trace_usage_aggregate_reconcile_complete", "traces", stats.TraceRowsUpdated)
}
}
}
if stores.Snapshots != nil {
backfiller, ok := stores.Snapshots.(snapshotCostBackfiller)
if ok {
stats, err := backfiller.BackfillSnapshotCosts(backfillCtx)
if err != nil {
slog.Warn("usage_pricing.snapshot_cost_backfill_failed", "error", err)
} else if stats.SnapshotRowsUpdated > 0 {
slog.Info("usage_pricing.snapshot_cost_backfill_complete", "snapshots", stats.SnapshotRowsUpdated)
}
}
}
if stores.UsageEvents != nil {
backfiller, ok := stores.UsageEvents.(usageEventCostBackfiller)
if ok {
stats, err := backfiller.BackfillUsageEventCosts(backfillCtx)
if err != nil {
slog.Warn("usage_pricing.usage_event_cost_backfill_failed", "error", err)
return
}
if stats.EventRowsUpdated > 0 || len(stats.RollupBuckets) > 0 {
slog.Info("usage_pricing.usage_event_cost_backfill_complete",
"events", stats.EventRowsUpdated,
"rollup_buckets", len(stats.RollupBuckets),
)
}
}
}
}
func runGateway() {
// Setup structured logging
logLevel := slog.LevelInfo
@@ -165,7 +287,9 @@ func runGateway() {
tools.DetectServerIPs(context.Background())
}
slog.Debug("creating mcpMgr via setupToolRegistry")
toolsReg, execApprovalMgr, mcpMgr, sandboxMgr, browserMgr, webFetchTool, ttsTool, audioMgr, permPE, toolPE, dataDir, agentCfg := setupToolRegistry(cfg, workspace, providerRegistry)
slog.Debug("setupToolRegistry completed", "mcpMgr_nil", mcpMgr == nil)
if browserMgr != nil {
defer browserMgr.Close()
}
@@ -178,6 +302,10 @@ func runGateway() {
browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies))
}
if ttsTool != nil && pgStores.SystemConfigs != nil {
ttsTool.SetSystemConfigStore(pgStores.SystemConfigs)
}
// Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'.
// Summon goroutines don't survive process restart; stale DB rows would trap the UI.
if pgStores.Agents != nil {
@@ -188,6 +316,24 @@ func runGateway() {
}
}
// Accepted async child runs are process-owned and cannot resume after a
// restart. Reconcile their durable rows before wiring tools or accepting
// traffic so completion lookups never remain queued/running forever.
if pgStores.SubagentTaskRecovery != nil {
startupCtx, stopStartup := signal.NotifyContext(
context.Background(), syscall.SIGINT, syscall.SIGTERM,
)
n, err := recoverInterruptedSubagentTasks(startupCtx, pgStores, time.Second)
stopStartup()
if err != nil {
slog.Info("subagent_tasks.recover_interrupted_aborted", "err", err)
return
}
if n > 0 {
slog.Info("subagent_tasks.recover_interrupted", "count", n)
}
}
if traceCollector != nil {
defer traceCollector.Stop()
// OTel OTLP export: compiled via build tags. Build with 'go build -tags otel' to enable.
@@ -226,7 +372,53 @@ func runGateway() {
slog.Info("system_configs applied to in-memory config", "keys", len(sysConfigs))
}
}
setupMemoryEmbeddings(pgStores, providerRegistry)
// Re-apply tool rate limiter using DB-overlaid config. setupToolRegistry
// initialised the limiter from the JSON5 default before ApplySystemConfigs
// ran, so DB-driven changes to tools.rate_limit_per_hour were lost. Replace
// the limiter object now that cfg reflects the DB value. Safe: server has
// not started, no in-flight tool calls.
if cfg.Tools.RateLimitPerHour > 0 {
toolsReg.SetRateLimiter(tools.NewToolRateLimiter(cfg.Tools.RateLimitPerHour))
slog.Info("tool rate limiting reapplied from system_configs", "per_hour", cfg.Tools.RateLimitPerHour)
} else {
toolsReg.SetRateLimiter(nil)
}
// Re-apply user-configured allowed paths for the same reason as the rate
// limiter above: setupToolRegistry wired the filesystem tools' AllowPaths
// from the JSON5 default before ApplySystemConfigs overlaid
// system_configs['allowed_paths'], so DB-driven paths never reached the tools.
// Re-run now that cfg reflects the DB value. Safe: server has not started, no
// in-flight tool calls.
if paths := cfg.Agents.Defaults.AllowedPaths; len(paths) > 0 {
applyUserAllowedPaths(toolsReg, paths)
slog.Info("filesystem allowed paths reapplied from system_configs", "paths", len(paths))
}
// MCP servers: load from database (single source of truth).
// pgStores.MCP is nil on SQLite/desktop builds that don't support MCP tables.
// Apply store to MCP manager so ListToolsForAgent can query DB.
// mcpMgr is created before pgStores is available, so the store must be set here.
if pgStores.MCP != nil && mcpMgr != nil {
mcpMgr.SetStore(pgStores.MCP)
slog.Info("applied store to MCPManager")
}
slog.Debug("checking MCP store availability", "pgStores_MCP_nil", pgStores == nil || pgStores.MCP == nil, "mcpMgr_nil", mcpMgr == nil)
if pgStores.MCP != nil {
slog.Debug("initializing MCP from database")
if err := initMCPFromDB(context.Background(), mcpMgr, pgStores.MCP); err != nil {
slog.Warn("mcp.db_load_errors", "error", err)
} else {
slog.Debug("initMCPFromDB completed successfully")
}
if mcpMgr != nil {
slog.Info("MCP manager started", "tools", len(mcpMgr.ToolNames()))
}
} else {
slog.Debug("skipping MCP database init: pgStores.MCP is nil")
}
teamWorkEmbedder := setupMemoryEmbeddings(pgStores, providerRegistry)
usageCapSvc := usagecaps.NewService(pgStores.UsageCaps, pgStores.Providers)
// Resolve background provider for consolidation + vault enrichment.
@@ -261,8 +453,10 @@ func runGateway() {
}
}
var channelMemorySvc *channelmemory.Service
if memorySvc := makeChannelMemoryService(pgStores, domainBus, providerRegistry, usageCapSvc); memorySvc != nil {
cleanupChannelMemory := (&channelmemory.Worker{Service: memorySvc}).Start(context.Background())
channelMemorySvc = memorySvc
cleanupChannelMemory := (&channelmemory.Worker{Service: channelMemorySvc}).Start(context.Background())
defer cleanupChannelMemory()
slog.Info("channel memory extraction worker registered")
}
@@ -304,7 +498,8 @@ func runGateway() {
}
// Subagent system (secureCLI store wired so subagent ExecTools enforce the gate)
subagentMgr := setupSubagents(providerRegistry, cfg, msgBus, toolsReg, workspace, sandboxMgr, pgStores.SecureCLI, usageCapSvc)
childRunAdmission := orchestration.NewChildRunAdmission(edition.Current().ChildRunLimit(), 128)
subagentMgr := setupSubagents(providerRegistry, cfg, msgBus, toolsReg, workspace, sandboxMgr, pgStores.SecureCLI, usageCapSvc, childRunAdmission)
if subagentMgr != nil {
// Wire announce queue for batched subagent result delivery (matching TS debounce pattern).
announceQueue := tools.NewAnnounceQueue(1000, 20, makeDelegateAnnounceCallback(subagentMgr, msgBus))
@@ -321,7 +516,7 @@ func runGateway() {
_ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity
// Register cron/heartbeat/session/message tools, aliases, allow-paths, store wiring.
heartbeatTool, hasMemory := wireExtraTools(pgStores, toolsReg, msgBus, workspace, dataDir, agentCfg, globalSkillsDir, builtinSkillsDir)
heartbeatTool, hasMemory := wireExtraTools(pgStores, toolsReg, msgBus, workspace, dataDir, agentCfg, globalSkillsDir, builtinSkillsDir, cfg.Cron.CommandEnabled)
// Register workstation_exec + claude_remote tools (Standard edition only; deny-all until Phase 6).
// cleanupWorkstation stops the activity sink retention goroutine and drains the write buffer.
@@ -340,8 +535,10 @@ func runGateway() {
server.SetVersion(Version)
server.SetDB(pgStores.DB)
server.SetPolicyEngine(permPE)
server.SetToolPolicy(toolPE)
server.SetPairingService(pgStores.Pairing)
server.SetMessageBus(msgBus)
server.SetExecApprovalManager(execApprovalMgr)
server.SetOAuthHandler(httpapi.NewOAuthHandler(pgStores.Providers, pgStores.ConfigSecrets, providerRegistry, msgBus))
// contextFileInterceptor is created inside wireExtras.
@@ -353,11 +550,77 @@ func runGateway() {
if pgStores.Agents != nil {
server.SetAgentStore(pgStores.Agents)
}
// Wire the skill/cron stores used by the CRUD MCP server (see
// internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()).
if pgStores.Skills != nil {
server.SetSkillStore(pgStores.Skills)
}
if pgStores.Cron != nil {
server.SetCronStore(pgStores.Cron)
}
if pgStores.AgentLinks != nil {
server.SetAgentLinkStore(pgStores.AgentLinks)
}
if pgStores.ConfigPermissions != nil {
server.SetConfigPermissionStore(pgStores.ConfigPermissions)
}
if pgStores.BitrixPortals != nil {
server.SetBitrixPortalStore(pgStores.BitrixPortals)
}
if pgStores.RunTimeline != nil {
server.SetRunTimelineStore(pgStores.RunTimeline)
}
if pgStores.Teams != nil {
server.SetTeamStore(pgStores.Teams)
}
if pgStores.ChannelInstances != nil {
server.SetChannelInstanceStore(pgStores.ChannelInstances)
}
if pgStores.Heartbeats != nil {
server.SetHeartbeatStore(pgStores.Heartbeats)
}
if pgStores.Providers != nil {
server.SetProviderStore(pgStores.Providers)
}
if pgStores.Tenants != nil {
server.SetTenantStore(pgStores.Tenants)
}
if pgStores.Memory != nil {
server.SetMemoryStore(pgStores.Memory)
}
if pgStores.KnowledgeGraph != nil {
server.SetKnowledgeGraphStore(pgStores.KnowledgeGraph)
}
if pgStores.Tracing != nil {
server.SetTracingStore(pgStores.Tracing)
}
if pgStores.Contacts != nil {
server.SetContactStore(pgStores.Contacts)
}
if pgStores.PendingMessages != nil {
server.SetPendingMessageStore(pgStores.PendingMessages)
}
if pgStores.Activity != nil {
server.SetActivityStore(pgStores.Activity)
}
if pgStores.SystemConfigs != nil {
server.SetSystemConfigStore(pgStores.SystemConfigs)
}
if pgStores.SecureCLI != nil {
server.SetSecureCLIStore(pgStores.SecureCLI)
}
server.SetSQLDB(pgStores.DB)
// Build OAuth token refresher before wireExtras so the resolver can inject tokens.
var mcpOAuthRefresher mcpbridge.OAuthTokenProvider
if pgStores != nil && pgStores.MCPOAuthTokens != nil {
mcpOAuthRefresher = mcpoauth.NewRefresher(pgStores.MCPOAuthTokens, security.NewSafeClient(15*time.Second))
}
var mcpPool *mcpbridge.Pool
var mediaStore *media.Store
var postTurn tools.PostTurnProcessor
contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, modelReg, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient, domainBus, usageCapSvc)
contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, modelReg, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient, domainBus, usageCapSvc, mcpOAuthRefresher, childRunAdmission)
if mcpPool != nil {
defer mcpPool.Stop()
}
@@ -374,11 +637,13 @@ func runGateway() {
skillsLoader: skillsLoader,
enrichProgress: enrichProgress,
enrichWorker: enrichWorker,
channelMemorySvc: channelMemorySvc,
workspace: workspace,
dataDir: dataDir,
domainBus: domainBus,
usageCapSvc: usageCapSvc,
audioMgr: audioMgr,
teamWorkEmbedder: teamWorkEmbedder,
}
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
@@ -387,6 +652,7 @@ func runGateway() {
mcpToolLister = mcpMgr
}
httpapi.InitGatewayToken(cfg.Gateway.Token)
mcpbridge.SetAllowedHosts(cfg.Gateway.MCPAllowedHosts) // operator allowlist: trusted MCP hosts exempt from private-IP SSRF block
httpapi.InitGatewayNoAuthFallbackAllowed(config.GatewayNoAuthFallbackAllowed(cfg.Gateway))
exportTokenStore := httpapi.InitExportTokenStore()
defer exportTokenStore.Stop()
@@ -395,11 +661,17 @@ func runGateway() {
// Wire dependencies for system prompt preview parity.
if agentsH != nil {
agentsH.SetPreviewDeps(toolsReg, skillsLoader)
agentsH.SetPreviewToolPolicy(toolPE)
var skillAccess store.SkillAccessStore
if pgStores.Skills != nil {
skillAccess, _ = pgStores.Skills.(store.SkillAccessStore)
}
agentsH.SetPreviewStores(pgStores.Teams, pgStores.AgentLinks, skillAccess)
slog.Debug("wiring MCP preview manager", "mcpMgr_nil", mcpMgr == nil)
if mcpMgr != nil {
agentsH.SetPreviewMCPManager(httpapi.NewMCPPreviewAdapter(mcpMgr))
slog.Debug("set MCP preview manager on agentsH")
}
}
// External wake/trigger API
@@ -408,6 +680,37 @@ func runGateway() {
wakeH.SetPostTurnProcessor(postTurn)
}
// MCP OAuth handler — per-server OAuth 2.1 client flows.
var mcpOAuthH *httpapi.MCPOAuthHandler
if pgStores != nil && pgStores.MCP != nil && pgStores.MCPOAuthTokens != nil {
safeHTTPClient := security.NewSafeClient(15 * time.Second)
var oauthRefresher *mcpoauth.Refresher
if r, ok := mcpOAuthRefresher.(*mcpoauth.Refresher); ok {
oauthRefresher = r
}
mcpOAuthH = httpapi.NewMCPOAuthHandler(httpapi.MCPOAuthHandlerDeps{
MCPStore: pgStores.MCP,
OAuthStore: pgStores.MCPOAuthTokens,
Discoverer: mcpoauth.NewDiscoverer(safeHTTPClient),
FlowMgr: mcpoauth.NewFlowManager(safeHTTPClient),
Refresher: oauthRefresher,
EventBus: msgBus,
PublicURL: cfg.Gateway.PublicURL,
Port: cfg.Gateway.Port,
TenantStore: pgStores.Tenants,
})
// Inject OAuth token provider into MCP tools handler so on-demand tool
// discovery can authenticate against OAuth-protected MCP servers.
if mcpH != nil && mcpOAuthRefresher != nil {
mcpH.SetOAuthProvider(mcpOAuthRefresher)
}
// Inject the OAuth token store so the update handler can purge stale tokens
// when a server's URL or OAuth config changes.
if mcpH != nil {
mcpH.SetOAuthStore(pgStores.MCPOAuthTokens)
}
}
// Wire all server.Set*Handler() calls via extracted helper.
deps.wireHTTPHandlersOnServer(
httpHandlers{
@@ -423,6 +726,7 @@ func runGateway() {
secureCLI: secureCLIH,
secureCLIGrant: secureCLIGrantH,
mcpUserCreds: mcpUserCredsH,
mcpOAuth: mcpOAuthH,
},
wakeH,
mcpPool,
@@ -447,7 +751,7 @@ func runGateway() {
// Register all RPC methods
server.SetLogTee(logTee)
server.SetRuntimeLogsHandler(httpapi.NewRuntimeLogsHandler(logTee))
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.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)
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Tracing, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, pgStores.AgentLinks, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc, providerRegistry, teamWorkEmbedder)
// Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history).
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
@@ -458,6 +762,7 @@ func runGateway() {
hm.SetTestRunner(methods.NewDispatcherTestRunner(sharedHookHandlers))
}
hm.Register(server.Router())
server.SetHookStore(hs)
slog.Info("registered hooks RPC methods")
}
@@ -480,6 +785,9 @@ func runGateway() {
chatMethods.SetPostTurnProcessor(postTurn)
server.SetPostTurnProcessor(postTurn) // HTTP: /v1/chat/completions, /v1/responses
wakeH.SetPostTurnProcessor(postTurn) // HTTP: /v1/agents/{id}/wake
if subagentMgr != nil {
subagentMgr.SetPostTurnProcessor(postTurn) // async spawns: detached from the parent turn
}
}
// Wire pairing event broadcasts to all WS clients.
@@ -498,7 +806,9 @@ func runGateway() {
// Channel manager
channelMgr := channels.NewManager(msgBus)
channelMgr.SetSystemMessages(systemmessages.NewResolver(cfg))
deps.channelMgr = channelMgr
server.SetChannelManager(channelMgr)
// Wire channel member resolver into permission grant paths (WS + HTTP) so
// file_writer grants coming from the Web UI auto-enrich their metadata.
@@ -510,12 +820,49 @@ func runGateway() {
// Bitrix24 channels (imbot.unregister bot cleanup).
channelInstancesH.SetChannelManager(channelMgr)
}
if deps.channelMemorySvc != nil {
deps.channelMemorySvc.ContextResolver = channelmemory.ContextResolverFunc(func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
return resolveChannelMemoryExtractionContext(ctx, channelMgr, inst, group)
})
}
// Wire channel sender + tenant checker on message tool (now that channelMgr exists)
if t, ok := toolsReg.Get("message"); ok {
if cs, ok := t.(tools.ChannelSenderAware); ok {
cs.SetChannelSender(channelMgr.SendToChannel)
}
if ce, ok := t.(tools.ChannelEditorAware); ok {
ce.SetChannelEditor(channelMgr.EditChannelMessage)
}
if rs, ok := t.(tools.ReactionSetterAware); ok {
rs.SetReactionSetter(channelMgr.ReactToMessage)
}
if tr, ok := t.(tools.TopicResolverAware); ok && pgStores != nil && pgStores.Contacts != nil {
contacts := pgStores.Contacts
tr.SetTopicResolver(func(ctx context.Context, channel, chatID, topicName string) (string, bool) {
list, err := contacts.ListContacts(ctx, store.ContactListOpts{
ChannelInstance: channel,
ContactType: "topic",
Limit: 500,
})
if err != nil {
return "", false
}
want := strings.ToLower(strings.TrimSpace(topicName))
for _, c := range list {
if c.SenderID != chatID || c.ThreadID == nil || c.DisplayName == nil {
continue
}
if strings.ToLower(strings.TrimSpace(*c.DisplayName)) == want {
return *c.ThreadID, true
}
}
return "", false
})
}
if tp, ok := t.(tools.TopicPosterAware); ok {
tp.SetTopicPoster(channelMgr.PostToTopic)
}
if tc, ok := t.(tools.ChannelTenantCheckerAware); ok {
tc.SetChannelTenantChecker(channelMgr.ChannelTenantID)
}
@@ -526,6 +873,28 @@ func runGateway() {
gl.SetGroupMemberLister(channelMgr.ListGroupMembers)
}
}
// Wire group lister on zalo_list_groups tool
if t, ok := toolsReg.Get("zalo_list_groups"); ok {
if gl, ok := t.(tools.GroupListerAware); ok {
gl.SetGroupLister(channelMgr.ListGroups)
}
}
// Wire Telegram manager on telegram_manager tool.
for _, toolName := range []string{"telegram_manager", "create_forum_topic"} {
if t, ok := toolsReg.Get(toolName); ok {
if tm, ok := t.(tools.TelegramManagerAware); ok {
tm.SetTelegramManager(channelMgr.ManageTelegram)
}
}
}
// Wire MCP server store on mcp_credential_manager tool.
if pgStores != nil && pgStores.MCP != nil {
if t, ok := toolsReg.Get("mcp_credential_manager"); ok {
if ms, ok := t.(tools.MCPServerStoreAware); ok {
ms.SetMCPServerStore(pgStores.MCP)
}
}
}
// Load channel instances from DB.
var instanceLoader *channels.InstanceLoader
@@ -536,7 +905,7 @@ func runGateway() {
instanceLoader.SetUsageCapService(usageCapSvc)
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStoresAndAudio(pgStores.Agents, pgStores.ConfigPermissions, pgStores.Teams, pgStores.SubagentTasks, pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithStoresAndAudio(pgStores.Agents, pgStores.ConfigPermissions, pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStoreAndAudio(pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithStoresAndAudio(pgStores.Agents, pgStores.ConfigPermissions, pgStores.PendingMessages, audioMgr))
instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory)
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDBAudio(pgStores.DB, pgStores.PendingMessages, "pgx", audioMgr, pgStores.BuiltinTools))
@@ -548,11 +917,14 @@ func runGateway() {
// the one used by pg.NewPGStores → NewPGBitrixPortalStore.
bitrixEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY")
// Use the MCP-aware factory variant so channels that opt into
// lazy per-user credential provisioning (via mcp_server_name +
// mcp_base_url in their instance config) can reach the partner's
// lazy per-user credential provisioning (via mcp_server_id — or
// the legacy mcp_server_name + mcp_base_url pair — in their
// instance config) can reach the partner's
// MCPServerStore. The MCP server authenticates each onboard call
// via the caller-supplied Bitrix access_token (Path B) — no shared
// admin secret is required. Channels with none of those set operate
// via the caller-supplied Bitrix access_token (the "Bitrix24
// OAuth → existing mcp_user_credentials bridge" — Bitrix-specific
// glue, not a generic MCP architecture pattern) — no shared admin
// secret is required. Channels with none of those set operate
// identically to before — the MCPStore arg is nil-safe inside the
// factory.
instanceLoader.RegisterFactory(channels.TypeBitrix24, bitrix24.FactoryWithPortalStoreAndMCP(pgStores.BitrixPortals, pgStores.MCP, bitrixEncKey))
@@ -569,6 +941,7 @@ func runGateway() {
pgStores.BitrixPortals,
pgStores.ChannelInstances,
server.PublicURLSnapshot().Get,
bitrixEncKey,
).Register(server.Router())
}
@@ -626,6 +999,10 @@ func runGateway() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go backfillTraceCostsAfterPricingSync(ctx, pgStores, snapshotWorker)
usagepricing.StartOpenRouterCatalogAutoSync(ctx, pgStores.UsageCaps, usagepricing.DefaultOpenRouterCatalogSyncInterval, func(syncCtx context.Context, _ int) {
backfillTraceCostsAfterPricingSync(syncCtx, pgStores, snapshotWorker)
})
server.StartUpdateChecker(ctx)
sigCh := make(chan os.Signal, 1)
@@ -683,6 +1060,7 @@ func runGateway() {
// Register quota usage RPC.
methods.NewQuotaMethods(quotaChecker, pgStores.DB).Register(server.Router())
server.SetQuotaChecker(quotaChecker)
// API key management RPC
if pgStores.APIKeys != nil {
@@ -718,9 +1096,11 @@ func runGateway() {
sandboxMgr: sandboxMgr,
postTurn: postTurn,
subagentMgr: subagentMgr,
childRunAdmission: childRunAdmission,
consumerTeamStore: consumerTeamStore,
auditCh: auditCh,
sigCh: sigCh,
terminateProcess: os.Exit,
})
}
+118 -13
View File
@@ -7,9 +7,11 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
geminiaudio "github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
minimaxaudio "github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
"github.com/nextlevelbuilder/goclaw/internal/audio/openaicompat"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -51,8 +53,10 @@ func resolveEmbeddingProvider(
}
}
// 2. Auto-detect: scan DB providers for first with settings.embedding.enabled
allProviders, err := providerStore.ListAllProviders(context.Background())
// 2. Auto-detect only from the master tenant. The selected provider receives
// cross-tenant semantic content during startup maintenance, so a tenant-owned
// endpoint must never be selected for this process-wide responsibility.
allProviders, err := providerStore.ListProviders(masterCtx)
if err != nil {
slog.Warn("failed to list providers for embedding auto-detect", "error", err)
return nil
@@ -160,7 +164,7 @@ func buildEmbeddingProvider(
return nil
}
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager, secureCLIStore store.SecureCLIStore, usageCapSvc *usagecaps.Service) *tools.SubagentManager {
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager, secureCLIStore store.SecureCLIStore, usageCapSvc *usagecaps.Service, admission *orchestration.ChildRunAdmission) *tools.SubagentManager {
names := providerReg.List(context.Background())
if len(names) == 0 {
return nil
@@ -208,8 +212,9 @@ func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus
return reg
}
manager := tools.NewSubagentManager(provider, providerReg, agentCfg.Model, msgBus, toolsFactory, subCfg)
manager := tools.NewSubagentManagerWithAdmission(provider, providerReg, agentCfg.Model, msgBus, toolsFactory, subCfg, admission)
manager.SetUsageCapService(usageCapSvc)
manager.SetAgentBudget(agentCfg.ContextWindow, agentCfg.MaxTokens)
return manager
}
@@ -226,19 +231,34 @@ func buildSubagentToolsRegistry(
) (*tools.Registry, *tools.ExecTool) {
reg := parentReg.Clone()
var execTool *tools.ExecTool
var readTool *tools.ReadFileTool
var writeTool *tools.WriteFileTool
var listTool *tools.ListFilesTool
if sandboxMgr != nil {
reg.Register(tools.NewSandboxedReadFileTool(workspace, restrict, sandboxMgr))
reg.Register(tools.NewSandboxedWriteFileTool(workspace, restrict, sandboxMgr))
reg.Register(tools.NewSandboxedListFilesTool(workspace, restrict, sandboxMgr))
readTool = tools.NewSandboxedReadFileTool(workspace, restrict, sandboxMgr)
writeTool = tools.NewSandboxedWriteFileTool(workspace, restrict, sandboxMgr)
listTool = tools.NewSandboxedListFilesTool(workspace, restrict, sandboxMgr)
execTool = tools.NewSandboxedExecTool(workspace, restrict, sandboxMgr)
reg.Register(execTool)
} else {
reg.Register(tools.NewReadFileTool(workspace, restrict))
reg.Register(tools.NewWriteFileTool(workspace, restrict))
reg.Register(tools.NewListFilesTool(workspace, restrict))
readTool = tools.NewReadFileTool(workspace, restrict)
writeTool = tools.NewWriteFileTool(workspace, restrict)
listTool = tools.NewListFilesTool(workspace, restrict)
execTool = tools.NewExecTool(workspace, restrict)
reg.Register(execTool)
}
// These four tools are built fresh, so they start with none of the hardening the
// gateway applied to the parent's instances at startup: exec path denials and their
// exemptions, shell deny-group toggles, the command keyword allowlist, and the
// read/write/list deny prefixes covering config.json, the databases, and delegate/.
// Without this, spawning a subagent widened reach — the parent could not touch the
// data dir, the subagent could. Inherit from the live parent instances so there is
// one source of truth and a later config reload cannot leave subagents behind.
inheritParentPathPolicy(parentReg, readTool, writeTool, listTool, execTool)
reg.Register(readTool)
reg.Register(writeTool)
reg.Register(listTool)
reg.Register(execTool)
// Red Team F3: subagent ExecTool must enforce the secure-CLI gate
// (and env scrub on fall-through) — without this, a parent agent
// can spawn a subagent to bypass the gate via host-inherited env.
@@ -248,6 +268,42 @@ func buildSubagentToolsRegistry(
return reg, execTool
}
// inheritParentPathPolicy copies the parent registry's tool hardening onto the freshly
// built subagent tools. A tool missing from the parent registry, or registered there
// under an unexpected concrete type, is skipped: the subagent then has no policy to
// inherit for it, which matches the parent having none to give.
func inheritParentPathPolicy(
parentReg *tools.Registry,
readTool *tools.ReadFileTool,
writeTool *tools.WriteFileTool,
listTool *tools.ListFilesTool,
execTool *tools.ExecTool,
) {
if parentReg == nil {
return
}
if pt, ok := parentReg.Get("read_file"); ok {
if parent, ok := pt.(*tools.ReadFileTool); ok {
readTool.InheritPathPolicy(parent)
}
}
if pt, ok := parentReg.Get("write_file"); ok {
if parent, ok := pt.(*tools.WriteFileTool); ok {
writeTool.InheritPathPolicy(parent)
}
}
if pt, ok := parentReg.Get("list_files"); ok {
if parent, ok := pt.(*tools.ListFilesTool); ok {
listTool.InheritPathPolicy(parent)
}
}
if pt, ok := parentReg.Get("exec"); ok {
if parent, ok := pt.(*tools.ExecTool); ok {
execTool.InheritSecurityPolicy(parent)
}
}
}
// setupTTS creates the TTS manager from config and registers providers.
// Edge TTS is always registered (free, no API key required).
// Always returns a non-nil manager with at least one provider.
@@ -273,6 +329,26 @@ func setupTTS(cfg *config.Config) *tts.Manager {
}))
}
// OpenAI-compatible self-hosted endpoint. api_base is the enable switch —
// unlike the vendor providers there is no API key to gate on, since these
// endpoints commonly have no auth.
if base := ttsCfg.OpenAICompat.APIBase; base != "" {
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: base,
APIKey: ttsCfg.OpenAICompat.APIKey,
TTSModel: ttsCfg.OpenAICompat.Model,
TTSVoice: ttsCfg.OpenAICompat.Voice,
TTSFormat: ttsCfg.OpenAICompat.Format,
TimeoutMs: ttsCfg.TimeoutMs,
})
if err != nil {
slog.Warn("audio.tts: openai_compat not registered", "error", err)
} else {
mgr.RegisterProvider(provider)
slog.Info("audio.tts: openai_compat registered", "api_base", base)
}
}
if key := ttsCfg.ElevenLabs.APIKey; key != "" {
mgr.RegisterProvider(tts.NewElevenLabsProvider(tts.ElevenLabsConfig{
APIKey: key,
@@ -357,6 +433,29 @@ func setupAudioExtras(cfg *config.Config, mgr *tts.Manager) {
}
}
// STT chain. Built from what actually registered, so Transcribe never walks
// a name it will only skip with a warning. "proxy" is always appended: it is
// registered later, per channel, by BridgeLegacySTT.
var sttChain []string
// OpenAI-compatible self-hosted endpoint, first when present: it is an
// explicit operator choice, and it keeps audio on the local network.
if base := cfg.Tts.OpenAICompat.APIBase; base != "" {
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{
APIBase: base,
APIKey: cfg.Tts.OpenAICompat.APIKey,
STTModel: cfg.Tts.OpenAICompat.STTModel,
TimeoutMs: cfg.Tts.TimeoutMs,
})
if err != nil {
slog.Warn("audio.stt: openai_compat not registered", "error", err)
} else {
mgr.RegisterSTT(provider)
sttChain = append(sttChain, provider.Name())
slog.Info("audio.stt: openai_compat registered", "api_base", base)
}
}
// ElevenLabs STT (Scribe v2) — reuse TTS credentials. Registered as tenant-scope
// default; per-request tenant override lands via builtin_tools[stt] in Phase 5
// channel migration. Legacy per-channel STTProxyURL is bridged separately.
@@ -365,7 +464,13 @@ func setupAudioExtras(cfg *config.Config, mgr *tts.Manager) {
APIKey: ellKey,
BaseURL: ellBase,
}))
mgr.SetSTTChain([]string{"elevenlabs", "proxy"})
sttChain = append(sttChain, "elevenlabs")
slog.Info("audio.stt: elevenlabs registered")
}
if len(sttChain) > 0 {
sttChain = append(sttChain, "proxy")
mgr.SetSTTChain(sttChain)
slog.Info("audio.stt: chain configured", "chain", sttChain)
}
}
+73 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -92,13 +93,22 @@ func TestSubagentExecTool_NilStoreIsSafe(t *testing.T) {
func captureEmbeddingRequest(t *testing.T, es *store.EmbeddingSettings) map[string]any {
t.Helper()
// Pin Docker detection off so the loopback httptest URL is not rewritten
// to host.docker.internal when this suite runs inside a container.
t.Cleanup(config.SetInDockerForTest(false))
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("Decode() error = %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2]}]}`))
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{
"embedding": make([]float32, store.RequiredMemoryEmbeddingDimensions),
"index": 0,
}},
})
}))
defer server.Close()
@@ -137,3 +147,65 @@ func TestBuildEmbeddingProviderIgnoresIncompatibleStoredDimensions(t *testing.T)
t.Fatalf("dimensions = %v, want fallback 1536", got)
}
}
type embeddingProviderStoreStub struct {
masterProviders []store.LLMProviderData
allProviders []store.LLMProviderData
listTenant uuid.UUID
listAllCalled bool
}
func (s *embeddingProviderStoreStub) CreateProvider(context.Context, *store.LLMProviderData) error {
return nil
}
func (s *embeddingProviderStoreStub) GetProvider(context.Context, uuid.UUID) (*store.LLMProviderData, error) {
return nil, nil
}
func (s *embeddingProviderStoreStub) GetProviderByName(context.Context, string) (*store.LLMProviderData, error) {
return nil, nil
}
func (s *embeddingProviderStoreStub) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) {
s.listTenant = store.TenantIDFromContext(ctx)
return s.masterProviders, nil
}
func (s *embeddingProviderStoreStub) ListAllProviders(context.Context) ([]store.LLMProviderData, error) {
s.listAllCalled = true
return s.allProviders, nil
}
func (s *embeddingProviderStoreStub) UpdateProvider(context.Context, uuid.UUID, map[string]any) error {
return nil
}
func (s *embeddingProviderStoreStub) DeleteProvider(context.Context, uuid.UUID) error { return nil }
func TestResolveEmbeddingProviderAutoDetectUsesMasterTenantOnly(t *testing.T) {
embeddingSettings := json.RawMessage(`{"embedding":{"enabled":true}}`)
providerStore := &embeddingProviderStoreStub{
masterProviders: []store.LLMProviderData{{
TenantID: store.MasterTenantID,
Name: "master-embedding",
ProviderType: store.ProviderOpenAICompat,
APIBase: "http://master.invalid/v1",
APIKey: "master-key",
Enabled: true,
Settings: embeddingSettings,
}},
allProviders: []store.LLMProviderData{{
TenantID: uuid.New(),
Name: "tenant-controlled-endpoint",
ProviderType: store.ProviderOpenAICompat,
Enabled: true,
Settings: embeddingSettings,
}},
}
provider := resolveEmbeddingProvider(providerStore, nil, nil)
if provider == nil || provider.Name() != "master-embedding" {
t.Fatalf("resolveEmbeddingProvider() = %v, want master-embedding", provider)
}
if providerStore.listTenant != store.MasterTenantID {
t.Fatalf("ListProviders tenant = %s, want master tenant", providerStore.listTenant)
}
if providerStore.listAllCalled {
t.Fatal("ListAllProviders was called; cross-tenant auto-detect must stay disabled")
}
}
+33 -14
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
@@ -37,13 +38,22 @@ func enqueueAnnounce(key string, entry announceEntry) bool {
// announceRouting holds the shared routing info captured by the first goroutine.
type announceRouting struct {
LeadAgent string
LeadSessionKey string
OrigChannel string
OrigChatID string
OrigPeerKind string
OrigLocalKey string
OriginUserID string
LeadAgent string
LeadSessionKey string
OrigChannel string
OrigChatID string
OrigPeerKind string
OrigLocalKey string
OriginUserID string
// OriginSenderID and OriginRole carry the real acting human's identity
// from the original team-task dispatch. Without them, the Lead's session
// resumes from this re-ingress with an empty SenderID, which then fails
// CheckFileWriterPermission / CheckCronPermission in group contexts
// ("system context cannot write files in group chats"). The subagent
// announce queue (subagentAnnounceRouting) already carries these fields
// — this keeps the team-task announce queue at parity. (#915 follow-up)
OriginSenderID string
OriginRole string
TeamID string
TeamWorkspace string
OriginTraceID string
@@ -62,6 +72,7 @@ func processAnnounceLoop(
teamStore store.TeamStore,
postTurn tools.PostTurnProcessor,
cfg *config.Config,
channelMgr *channels.Manager,
) {
for {
entries := teamAnnounceQueue.Drain(r.LeadSessionKey)
@@ -83,13 +94,21 @@ func processAnnounceLoop(
content := buildMergedAnnounceContent(entries, snapshot, r.TeamWorkspace)
req := agent.RunRequest{
SessionKey: r.LeadSessionKey,
Message: content,
Channel: r.OrigChannel,
ChatID: r.OrigChatID,
PeerKind: r.OrigPeerKind,
LocalKey: r.OrigLocalKey,
UserID: r.OriginUserID,
SessionKey: r.LeadSessionKey,
Message: content,
Channel: r.OrigChannel,
ChatTitle: resolveGroupDisplayTitle(ctx, channelMgr, r.OrigChannel, r.OrigChatID, r.OrigPeerKind, ""),
ChatID: r.OrigChatID,
PeerKind: r.OrigPeerKind,
LocalKey: r.OrigLocalKey,
UserID: r.OriginUserID,
// SenderID + Role propagate the original human acting through this
// team-task announce. loop_context.injectContext gates WithSenderID
// on req.SenderID being non-empty, so missing them silently strips
// the Lead's identity on resume — and group-scoped permission
// checks then deny write_file etc. (#915 follow-up)
SenderID: r.OriginSenderID,
Role: r.OriginRole,
RunID: fmt.Sprintf("teammate-announce-%s-%d", r.LeadAgent, len(entries)),
RunKind: "announce",
HideInput: true,
+74
View File
@@ -0,0 +1,74 @@
package cmd
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// TestAnnounceRouting_PropagatesSenderAndRole guards against the regression
// where team-task completion announces drop SenderID/Role on re-ingress to
// the Lead session — the failure mode reported as
// `permission denied: system context cannot write files in group chats`
// when the Lead tries write_file inside the announce-triggered turn.
//
// team_tool_dispatch.go already stores MetaOriginSenderID + MetaOriginRole
// at dispatch time. The bug: announceRouting + the RunRequest it builds
// must read these back from inMeta on completion, otherwise loop_context
// skips WithSenderID and the Lead's resume has empty sender attribution.
func TestAnnounceRouting_PropagatesSenderAndRole(t *testing.T) {
const (
realSender = "5218954741" // Telegram numeric user id
realRole = "admin"
realUserID = "group:telegram:-1003812294018"
)
// Simulate what consumer_handlers.go reads from a teammate-message inMeta.
inMeta := map[string]string{
tools.MetaOriginSenderID: realSender,
tools.MetaOriginRole: realRole,
tools.MetaOriginUserID: realUserID,
tools.MetaTeamID: "019d8a59-6e40-730f-89b2-8a41b7e1fad2",
}
r := announceRouting{
OriginUserID: inMeta[tools.MetaOriginUserID],
OriginSenderID: inMeta[tools.MetaOriginSenderID],
OriginRole: inMeta[tools.MetaOriginRole],
TeamID: inMeta[tools.MetaTeamID],
}
if r.OriginSenderID != realSender {
t.Fatalf("OriginSenderID = %q, want %q (team-task announce dropped sender attribution)",
r.OriginSenderID, realSender)
}
if r.OriginRole != realRole {
t.Fatalf("OriginRole = %q, want %q (team-task announce dropped RBAC role)",
r.OriginRole, realRole)
}
if r.OriginUserID != realUserID {
t.Fatalf("OriginUserID = %q, want %q", r.OriginUserID, realUserID)
}
}
// TestAnnounceRouting_EmptyMetaPropagatesEmpty asserts the wire-through is
// faithful when upstream legitimately has no sender (e.g. a system-initiated
// dispatch). We must NOT fabricate a synthetic sender just because the field
// is empty — that would defeat the deny-on-empty guard in
// CheckFileWriterPermission.
func TestAnnounceRouting_EmptyMetaPropagatesEmpty(t *testing.T) {
inMeta := map[string]string{
tools.MetaTeamID: "team-uuid",
}
r := announceRouting{
OriginUserID: inMeta[tools.MetaOriginUserID],
OriginSenderID: inMeta[tools.MetaOriginSenderID],
OriginRole: inMeta[tools.MetaOriginRole],
}
if r.OriginSenderID != "" {
t.Errorf("OriginSenderID = %q, want empty (no upstream sender to propagate)", r.OriginSenderID)
}
if r.OriginRole != "" {
t.Errorf("OriginRole = %q, want empty", r.OriginRole)
}
}
+26
View File
@@ -26,6 +26,13 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Metadata: json.RawMessage(`{"config_hint":"Config → Tools → Exec Approval"}`),
},
{Name: "wait", DisplayName: "Wait", Description: "Pause the current agent tool sequence for a bounded number of milliseconds", Category: "runtime", Enabled: true},
{Name: "datetime", DisplayName: "Date/Time", Description: "Get the current date and time with timezone support, for precise timestamps in scheduling and memory", Category: "runtime", Enabled: true},
{Name: "workstation_exec", DisplayName: "Workstation Exec", Description: "Execute an allowlisted command on a linked remote workstation", Category: "runtime", Enabled: true,
Requires: []string{"workstation"},
},
{Name: "claude_remote", DisplayName: "Claude Remote", Description: "Run Claude Code CLI on a remote workstation via workstation_exec", Category: "runtime", Enabled: true,
Requires: []string{"workstation"},
},
// web
{Name: "web_search", DisplayName: "Web Search", Description: "Search the web for information using a search engine (Brave or DuckDuckGo)", Category: "web", Enabled: true,
@@ -46,6 +53,15 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Settings: json.RawMessage(`{"extract_on_memory_write":false,"extraction_provider":"","extraction_model":"","min_confidence":0.75}`),
Requires: []string{"knowledge_graph"},
},
{Name: "memory_expand", DisplayName: "Memory Expand", Description: "Expand a memory search result with surrounding context from the same document", Category: "memory", Enabled: true,
Requires: []string{"memory"},
},
{Name: "vault_search", DisplayName: "Vault Search", Description: "Search the Knowledge Vault (documents, wikilinks, episodic and knowledge graph fan-out)", Category: "vault", Enabled: true,
Requires: []string{"vault"},
},
{Name: "vault_read", DisplayName: "Vault Read", Description: "Read the full content of a Knowledge Vault document by doc_id", Category: "vault", Enabled: true,
Requires: []string{"vault"},
},
// media — user must configure provider chain via UI before use
{Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: false,
@@ -93,22 +109,32 @@ func builtinToolSeedData() []store.BuiltinToolDef {
// messaging
{Name: "message", DisplayName: "Message", Description: "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)", Category: "messaging", Enabled: true},
{Name: "send_file", DisplayName: "Send File", Description: "Send an existing workspace file as an attachment in the current chat (does not create or modify the file)", Category: "messaging", Enabled: true},
{Name: "create_forum_topic", DisplayName: "Create Telegram Forum Topic", Description: "Create a Telegram forum topic and return its message_thread_id for routing", Category: "messaging", Enabled: true},
{Name: "list_group_members", DisplayName: "List Group Members", Description: "List the members of the current group chat", Category: "messaging", Enabled: true},
{Name: "zalo_list_groups", DisplayName: "Zalo List Groups", Description: "Resolve a Zalo group's real chat ID from its display name", Category: "messaging", Enabled: true},
// scheduling
{Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule or manage recurring tasks using cron expressions, at-times, or intervals", Category: "scheduling", Enabled: true,
Metadata: json.RawMessage(`{"config_hint":"Config → Cron"}`),
},
{Name: "heartbeat", DisplayName: "Heartbeat", Description: "Schedule or manage the agent's recurring self-check-in heartbeat", Category: "scheduling", Enabled: true},
// subagents
{Name: "spawn", DisplayName: "Spawn", Description: "Spawn a subagent to handle a task in the background", Category: "subagents", Enabled: true,
Metadata: json.RawMessage(`{"config_hint":"Config → Agents Defaults"}`),
},
{Name: "delegate", DisplayName: "Delegate", Description: "Delegate a task to a linked agent for inter-agent orchestration", Category: "subagents", Enabled: true,
Requires: []string{"agent_links"},
},
// skills
{Name: "skill_search", DisplayName: "Skill Search", Description: "Search for available skills by keyword or description to find relevant capabilities", Category: "skills", Enabled: true},
{Name: "use_skill", DisplayName: "Use Skill", Description: "Activate a skill to use its specialized capabilities (tracing marker)", Category: "skills", Enabled: true},
{Name: "publish_skill", DisplayName: "Publish Skill", Description: "Register a skill directory (created via skill-creator) in the system database, making it discoverable and grantable to agents", Category: "skills", Enabled: true},
{Name: "skill_manage", DisplayName: "Skill Manager", Description: "Create, patch, or delete skills from conversation experience", Category: "skills", Enabled: true},
{Name: "mcp_tool_search", DisplayName: "MCP Tool Search", Description: "Search for available MCP external integration tools by keyword (search mode, deferred loading)", Category: "skills", Enabled: true,
Requires: []string{"mcp"},
},
// teams
{Name: "team_tasks", DisplayName: "Team Tasks", Description: "View, create, update, and complete tasks on the team task board", Category: "teams", Enabled: true,
+49
View File
@@ -18,3 +18,52 @@ func TestBuiltinToolSeedDataIncludesWait(t *testing.T) {
}
t.Fatal("builtinToolSeedData() missing wait")
}
func TestBuiltinToolSeedDataIncludesMemoryExpand(t *testing.T) {
t.Parallel()
for _, def := range builtinToolSeedData() {
if def.Name != "memory_expand" {
continue
}
if def.Category != "memory" {
t.Fatalf("memory_expand category = %q, want memory", def.Category)
}
if !def.Enabled {
t.Fatal("memory_expand should be enabled by default")
}
if len(def.Requires) != 1 || def.Requires[0] != "memory" {
t.Fatalf("memory_expand requires = %#v, want [memory]", def.Requires)
}
return
}
t.Fatal("builtinToolSeedData() missing memory_expand")
}
func TestBuiltinToolSeedDataMemoryCategoryIncludesRecallTools(t *testing.T) {
t.Parallel()
got := map[string]bool{}
for _, def := range builtinToolSeedData() {
if def.Category == "memory" {
got[def.Name] = true
}
}
for _, want := range []string{
"memory_search",
"memory_get",
"memory_expand",
"knowledge_graph_search",
} {
if !got[want] {
t.Fatalf("memory category missing %s; got %#v", want, got)
}
}
}
func TestBuiltinToolSeedDataDoesNotIncludeTelegramManager(t *testing.T) {
t.Parallel()
for _, def := range builtinToolSeedData() {
if def.Name == "telegram_manager" {
t.Fatal("telegram_manager must be configured from Telegram channel settings, not seeded as a visible builtin tool")
}
}
}
+16 -3
View File
@@ -24,6 +24,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -74,7 +75,7 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms
if strings.Contains(fmt.Sprintf("%T", pgStores.DB.Driver()), "sqlite") {
waDialect = "sqlite3"
}
wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect, audioMgr, pgStores.BuiltinTools)
wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect, audioMgr, pgStores.BuiltinTools, whatsapp.WithLegacyFirstDeviceFallback())
if err != nil {
channelMgr.RecordFailure(channels.TypeWhatsApp, "", err)
slog.Error("failed to initialize whatsapp channel", "error", err)
@@ -216,13 +217,16 @@ func wireChannelEventSubscribers(
// Wire pairing approval notification → channel (matching TS notifyPairingApproved).
botName := cfg.ResolveDisplayName("default")
messageResolver := systemmessages.NewResolver(cfg)
pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) {
// Browser/internal channels use WebSocket — UI polls approval status directly.
if channels.IsInternalChannel(channel) {
slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel)
return
}
msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName)
msg := messageResolver.Render("", systemmessages.KeyPairingApproved, systemmessages.Vars{
"app_name": botName,
})
// Group pairings need group_id metadata so channels (e.g. Zalo) route to group API.
if strings.HasPrefix(senderID, "group:") {
msgBus.PublishOutbound(bus.OutboundMessage{
@@ -236,7 +240,9 @@ func wireChannelEventSubscribers(
}
})
// Wire pairing revocation → force disconnect active WebSocket sessions.
// Wire pairing revocation → force disconnect active WebSocket sessions and
// clear the in-memory group approval cache so a revoked group re-enters the
// pairing gate on its next message instead of the bot replying as usual.
msgBus.Subscribe(bus.TopicPairingRevoked, func(event bus.Event) {
if event.Name != bus.EventPairingRevoked {
return
@@ -246,6 +252,13 @@ func wireChannelEventSubscribers(
return
}
go server.DisconnectByPairing(payload.SenderID, payload.Channel)
// Group pairings use "group:<chatID>" as sender ID (telegram) or
// "<chatID>" (other channels); only group entries carry an
// approvedGroups cache entry worth clearing.
if groupChatID, isGroup := strings.CutPrefix(payload.SenderID, "group:"); isGroup {
slog.Debug("pairing revoked, clearing group approval cache", "channel", payload.Channel, "chat_id", groupChatID)
channelMgr.ClearGroupApproval(payload.Channel, groupChatID)
}
})
// Cascade: when an agent becomes inactive, disable its linked channel instances.
+52
View File
@@ -0,0 +1,52 @@
package cmd
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestDrainChildRunsWithRetryReturnsTypedFailure(t *testing.T) {
admission := orchestration.NewChildRunAdmission(1, 2)
started := make(chan struct{})
release := make(chan struct{})
ticket, err := admission.Enqueue(context.Background(), orchestration.ChildRunConstraints{
TenantID: store.MasterTenantID,
TaskID: uuid.NewString(),
}, func(context.Context, *orchestration.ChildRunLease) {
close(started)
<-release
})
if err != nil {
t.Fatal(err)
}
if err := ticket.Activate(); err != nil {
t.Fatal(err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("child run did not start")
}
err = drainChildRunsWithRetry(admission, 10*time.Millisecond, 10*time.Millisecond)
if !errors.Is(err, orchestration.ErrChildRunDrainTimeout) {
t.Fatalf("drain error = %v, want typed timeout", err)
}
close(release)
select {
case <-ticket.Done():
case <-time.After(time.Second):
t.Fatal("child run did not finish after forced termination signal")
}
if err := admission.Close(context.Background()); err != nil {
t.Fatalf("final drain: %v", err)
}
}
+5 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -27,7 +28,7 @@ import (
// and routes them through the scheduler/agent loop, then publishes the response back.
// Also handles subagent announcements: routes them through the parent agent's session
// (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user.
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry) {
func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, agentLinkStore store.AgentLinkStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor, subagentMgr *tools.SubagentManager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry, teamWorkEmbedder memory.EmbeddingProvider) {
slog.Info("inbound message consumer started")
// Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts).
@@ -53,6 +54,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
ChannelMgr: channelMgr,
MsgBus: msgBus,
TeamStore: teamStore,
AgentLinkStore: agentLinkStore,
AgentStore: agentStore,
SessStore: sessStore,
PostTurn: postTurn,
@@ -61,6 +63,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
SubagentMgr: subagentMgr,
UsageCaps: usageCapSvc,
ProviderReg: providerReg,
TeamWorkEmbedder: teamWorkEmbedder,
GetAnnounceMu: getAnnounceMu,
}
@@ -144,7 +147,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
}
// --- Normal messages: route through debouncer ---
prepareInboundDebounceMessage(&msg, deps)
prepareInboundDebounceMessage(ctx, &msg, deps)
debouncer.Push(msg)
}
}
+3 -2
View File
@@ -18,11 +18,12 @@ import (
// debouncing (issue #63). See plans/260528-1351-multi-attachment-debounce/.
const mediaDebounceFloorMs = 1000
func prepareInboundDebounceMessage(msg *bus.InboundMessage, deps *ConsumerDeps) {
func prepareInboundDebounceMessage(ctx context.Context, msg *bus.InboundMessage, deps *ConsumerDeps) {
if msg == nil || deps == nil || deps.Cfg == nil || msg.AgentID != "" {
return
}
msg.AgentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
routeCtx := inboundMessageTenantContext(ctx, *msg)
msg.AgentID = resolveAgentRouteForInbound(routeCtx, deps.Cfg, deps.AgentStore, msg.Channel, msg.ChatID, msg.PeerKind)
}
func resolveInboundDebounceDelay(ctx context.Context, msg bus.InboundMessage, deps *ConsumerDeps) time.Duration {
+3
View File
@@ -7,6 +7,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -23,6 +24,7 @@ type ConsumerDeps struct {
ChannelMgr *channels.Manager
MsgBus *bus.MessageBus
TeamStore store.TeamStore
AgentLinkStore store.AgentLinkStore
AgentStore store.AgentStore
SessStore store.SessionStore
PostTurn tools.PostTurnProcessor
@@ -32,6 +34,7 @@ type ConsumerDeps struct {
SubagentMgr *tools.SubagentManager
UsageCaps *usagecaps.Service
ProviderReg *providers.Registry
TeamWorkEmbedder memory.EmbeddingProvider
BgWg sync.WaitGroup
GetAnnounceMu func(string) *sync.Mutex
}
+62 -17
View File
@@ -79,6 +79,7 @@ func handleSubagentAnnounce(
if sid := msg.Metadata[tools.MetaOriginRootSpanID]; sid != "" {
parentRootSpanID, _ = uuid.Parse(sid)
}
rootAgentID, _ := uuid.Parse(msg.Metadata[tools.MetaSubagentRootAgentID])
// Group-scoped UserID for subagent announce (same logic as main lane).
announceUserID := msg.UserID
@@ -116,9 +117,7 @@ func handleSubagentAnnounce(
originSenderID := msg.Metadata[tools.MetaOriginSenderID]
originRole := msg.Metadata[tools.MetaOriginRole]
queueKey := fmt.Sprintf("%s:%s", msg.TenantID, sessionKey)
routing := subagentAnnounceRouting{
QueueKey: queueKey,
SessionKey: sessionKey,
TenantID: msg.TenantID,
OrigChannel: origChannel,
@@ -130,10 +129,16 @@ func handleSubagentAnnounce(
SenderID: originSenderID,
Role: originRole,
ParentAgent: parentAgent,
RootAgentID: rootAgentID,
ParentTraceID: parentTraceID,
ParentRootSpanID: parentRootSpanID,
OutMeta: buildAnnounceOutMeta(origLocalKey),
}
// Batch only announces with identical routing and authority. A session can
// be shared by multiple group senders or local topic keys; using only
// tenant+session would let the first item's sender/role govern the rest.
queueKey := subagentAnnounceRoutingKey(routing)
routing.QueueKey = queueKey
// Enqueue into producer-consumer queue using tenant-scoped key from routing.
isProcessor := enqueueSubagentAnnounce(queueKey, entry)
@@ -142,15 +147,33 @@ func handleSubagentAnnounce(
defer safego.Recover(nil, "component", "subagent_announce_loop", "session", sessionKey)
// Fetch live roster for merged announce context.
roster := deps.SubagentMgr.RosterForParent(parentAgent)
roster := deps.SubagentMgr.RosterForParent(tools.TaskScope{
TenantID: msg.TenantID, RootAgentID: rootAgentID, RootAgentKey: parentAgent,
})
processSubagentAnnounceLoop(ctx, routing, roster, deps.SubagentMgr, deps.Sched, deps.MsgBus, deps.Cfg)
processSubagentAnnounceLoop(ctx, routing, roster, deps.SubagentMgr, deps.Sched, deps.MsgBus, deps.Cfg, deps.ChannelMgr)
})
}
return true
}
func subagentAnnounceRoutingKey(r subagentAnnounceRouting) string {
return strings.Join([]string{
r.TenantID.String(),
r.RootAgentID.String(),
r.ParentAgent,
r.SessionKey,
r.OrigChannel,
r.OrigChatID,
r.OrigPeerKind,
r.OrigLocalKey,
r.UserID,
r.SenderID,
r.Role,
}, "\x00")
}
// handleTeammateMessage processes teammate messages: bypass debounce, route to target
// agent session using the "team" lane, then announce result back to lead.
// Returns true if the message was handled (caller should continue).
@@ -246,13 +269,24 @@ func handleTeammateMessage(
Channel: origChannel,
ChannelType: origChannelType,
ChatID: origChatID,
ChatTitle: resolveGroupDisplayTitle(schedCtx, deps.ChannelMgr, origChannel, origChatID, origPeerKind, ""),
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
SenderID: teammateSenderID, // real user who triggered the teammate dispatch (#915)
Role: teammateRole, // RBAC role for admin bypass during teammate turn (#915)
RunID: fmt.Sprintf("teammate-%s-%s", msg.Metadata[tools.MetaFromAgent], msg.Metadata[tools.MetaToAgent]),
Stream: false,
// Streamed for connection liveness, not for delivery. A teammate run is
// never registered with the channel manager, so HandleAgentEvent drops its
// chunks on the first line and nothing is delivered incrementally; the task
// result still comes from the final RunResult. What streaming buys is the
// response headers arriving immediately: a non-streamed request to a slow
// reasoning model holds a silent connection for the whole generation, and
// ResponseHeaderTimeout kills it — observed as
// `http2: timeout awaiting response headers` on a member asked to produce a
// large file, while the same model over the same provider was fine on a
// streamed channel run.
Stream: true,
TeamTaskID: msg.Metadata[tools.MetaTeamTaskID],
TeamWorkspace: msg.Metadata[tools.MetaTeamWorkspace],
LeaderAgentID: msg.Metadata[tools.MetaLeaderAgentID],
@@ -363,13 +397,19 @@ func handleTeammateMessage(
}
routing := announceRouting{
LeadAgent: leadAgent,
LeadSessionKey: leadSessionKey,
OrigChannel: origCh,
OrigChatID: origChatID,
OrigPeerKind: origPeerKind,
OrigLocalKey: origLocalKey,
OriginUserID: inMeta[tools.MetaOriginUserID],
LeadAgent: leadAgent,
LeadSessionKey: leadSessionKey,
OrigChannel: origCh,
OrigChatID: origChatID,
OrigPeerKind: origPeerKind,
OrigLocalKey: origLocalKey,
OriginUserID: inMeta[tools.MetaOriginUserID],
// Carry the real acting sender + role through the team-task
// announce so the Lead's resumed turn doesn't lose attribution
// and trip group-scope permission checks. team_tool_dispatch.go
// already populates these fields in the dispatch metadata. (#915)
OriginSenderID: inMeta[tools.MetaOriginSenderID],
OriginRole: inMeta[tools.MetaOriginRole],
TeamID: inMeta[tools.MetaTeamID],
TeamWorkspace: inMeta[tools.MetaTeamWorkspace],
OriginTraceID: inMeta[tools.MetaOriginTraceID],
@@ -377,7 +417,7 @@ func handleTeammateMessage(
ParentRootSpanID: parentRootSpanID,
OutMeta: outMeta,
}
processAnnounceLoop(ctx, routing, deps.Sched, deps.MsgBus, deps.TeamStore, deps.PostTurn, deps.Cfg)
processAnnounceLoop(ctx, routing, deps.Sched, deps.MsgBus, deps.TeamStore, deps.PostTurn, deps.Cfg, deps.ChannelMgr)
}(origChannel, origChatID, msg.SenderID, taskIDStr, outMeta, msg.Metadata)
return true
@@ -395,7 +435,8 @@ func handleResetCommand(
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
ctx := inboundMessageTenantContext(context.Background(), msg)
agentID = resolveAgentRouteForInbound(ctx, deps.Cfg, deps.AgentStore, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
@@ -431,7 +472,8 @@ func handleStopCommand(
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
ctx := inboundMessageTenantContext(context.Background(), msg)
agentID = resolveAgentRouteForInbound(ctx, deps.Cfg, deps.AgentStore, msg.Channel, msg.ChatID, msg.PeerKind)
}
peerKind := msg.PeerKind
if peerKind == "" {
@@ -551,11 +593,14 @@ func buildTeammateAnnounce(ctx context.Context, outcome scheduler.RunOutcome, se
} else if outcome.Result == nil {
slog.Warn("teammate message: nil result without error", "from", senderID)
return "", nil, false
} else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
} else if normalized, shouldDeliver := normalizeAgentOutboundContent(
outcome.Result.Content,
len(outcome.Result.Media),
); !shouldDeliver {
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
return "", nil, false
} else {
content = outcome.Result.Content
content = normalized
media = outcome.Result.Media
}
+75 -14
View File
@@ -1,16 +1,20 @@
package cmd
import (
"context"
"fmt"
"mime"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -19,26 +23,60 @@ import (
// Matching TS resolve-route.ts binding resolution.
func resolveAgentRoute(cfg *config.Config, channel, chatID, peerKind string) string {
for _, binding := range cfg.Bindings {
match := binding.Match
if match.Channel != channel {
continue
if bindingMatchesInbound(binding, channel, chatID, peerKind) {
return config.NormalizeAgentID(binding.AgentID)
}
// Peer-level match (most specific)
if match.Peer != nil {
if match.Peer.Kind == peerKind && match.Peer.ID == chatID {
return config.NormalizeAgentID(binding.AgentID)
}
continue // has peer constraint but doesn't match — skip
}
// Channel-level match (least specific, no peer constraint)
return config.NormalizeAgentID(binding.AgentID)
}
return cfg.ResolveDefaultAgentID()
}
type defaultAgentGetter interface {
GetDefault(ctx context.Context) (*store.AgentData, error)
}
func resolveAgentRouteForInbound(ctx context.Context, cfg *config.Config, agentStore defaultAgentGetter, channel, chatID, peerKind string) string {
if cfg == nil {
return config.DefaultAgentID
}
for _, binding := range cfg.Bindings {
if bindingMatchesInbound(binding, channel, chatID, peerKind) {
return config.NormalizeAgentID(binding.AgentID)
}
}
if agentStore != nil {
if ag, err := agentStore.GetDefault(ctx); err == nil && ag != nil && ag.AgentKey != "" {
return ag.AgentKey
}
}
return cfg.ResolveDefaultAgentID()
}
func bindingMatchesInbound(binding config.AgentBinding, channel, chatID, peerKind string) bool {
match := binding.Match
if match.Channel != channel {
return false
}
// Peer-level match (most specific)
if match.Peer != nil {
return match.Peer.Kind == peerKind && match.Peer.ID == chatID
}
// Channel-level match (least specific, no peer constraint)
return true
}
func inboundMessageTenantContext(ctx context.Context, msg bus.InboundMessage) context.Context {
if ctx == nil {
ctx = context.Background()
}
if msg.TenantID != uuid.Nil {
return store.WithTenantID(ctx, msg.TenantID)
}
return store.WithTenantID(ctx, store.MasterTenantID)
}
// overrideSessionKeyFromLocalKey extracts topic/thread ID from the composite
// local_key and returns the correct session key for forum topics or DM threads.
// If localKey is empty or has no suffix, the original sessionKey is returned unchanged.
@@ -91,6 +129,29 @@ func extractSessionMetadata(msg bus.InboundMessage, peerKind string) map[string]
return meta
}
// resolveGroupDisplayTitle fills presentation context for internally
// re-ingressed group messages that no longer carry inbound chat metadata.
// It leaves routing identifiers untouched and silently falls back when a
// channel cannot resolve a platform-specific display title.
func resolveGroupDisplayTitle(ctx context.Context, mgr *channels.Manager, channel, chatID, peerKind, title string) string {
if title != "" || peerKind != string(sessions.PeerGroup) || mgr == nil || channel == "" || chatID == "" {
return title
}
resolved, err := mgr.ResolveGroupDisplayTitle(ctx, channel, chatID)
if err != nil {
return title
}
return resolved
}
func resolveInboundChatTitle(ctx context.Context, mgr *channels.Manager, msg bus.InboundMessage, peerKind string) string {
title := msg.Metadata[tools.MetaChatTitle]
if !bus.IsInternalSender(msg.SenderID) {
return title
}
return resolveGroupDisplayTitle(ctx, mgr, msg.Channel, msg.ChatID, peerKind, title)
}
// buildPancakeSessionLabel returns "Pancake:{senderName}:{pageName}" with non-empty parts only.
func buildPancakeSessionLabel(senderName, pageName string) string {
label := "Pancake"
+120 -40
View File
@@ -13,6 +13,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/bitrix24"
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram/voiceguard"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
@@ -39,20 +40,50 @@ func processNormalMessage(
// Determine target agent via bindings or explicit AgentID
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(deps.Cfg, msg.Channel, msg.ChatID, msg.PeerKind)
agentID = resolveAgentRouteForInbound(ctx, deps.Cfg, deps.AgentStore, msg.Channel, msg.ChatID, msg.PeerKind)
}
agentLoop, err := deps.Agents.Get(ctx, agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel, "error", err)
errContent := formatAgentError(err)
if deps.ChannelMgr != nil {
if ct := deps.ChannelMgr.ChannelTypeForName(msg.Channel); isExternalChannel(ct) {
errContent = ""
}
}
deps.MsgBus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: errContent,
Metadata: msg.Metadata,
TenantID: msg.TenantID,
})
return
}
// Team Work and intent gates run before Loop.injectContext. Propagate the
// resolved agent budget here so every classifier sees the same authority.
ctx = agent.WithAgentBudget(ctx, agentLoop)
if uid := agentLoop.UUID(); uid != uuid.Nil {
ctx = store.WithAgentID(ctx, uid)
}
// Build session key based on scope config (matching TS buildAgentPeerSessionKey).
peerKind := msg.PeerKind
if peerKind == "" {
peerKind = string(sessions.PeerDirect) // default to DM
}
// Channel adapters already attach cache-only titles to external inbound
// messages. Resolve live hierarchy only for synthetic/re-ingressed messages,
// where no adapter metadata exists; this keeps Discord REST off the user
// message hot path.
chatTitle := resolveInboundChatTitle(ctx, deps.ChannelMgr, msg, peerKind)
if chatTitle != "" && chatTitle != msg.Metadata[tools.MetaChatTitle] {
if msg.Metadata == nil {
msg.Metadata = make(map[string]string)
}
msg.Metadata[tools.MetaChatTitle] = chatTitle
}
sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID)
// Thread-based isolation override (e.g. Slack DM threads, AI Panel)
@@ -83,21 +114,10 @@ func processNormalMessage(
}
// Group-scoped UserID: context files, memory, traces, and seeding scope.
// - Discord guilds: "guild:{guildID}:user:{senderID}" — per-user per-server,
// shared across all channels within the same server. Session key stays per-channel.
// - Other platforms: "group:{channel}:{chatID}" — shared by all users in the chat.
// Individual senderID is preserved in InboundMessage for pairing/dedup/mention gate.
userID := msg.UserID
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
if guildID := msg.Metadata["guild_id"]; guildID != "" && msg.SenderID != "" {
// Discord guild: per-user scope so each member has own profile
// across all channels in the same server.
userID = fmt.Sprintf("guild:%s:user:%s", guildID, msg.SenderID)
} else {
groupID := msg.ChatID
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
}
// See deriveGroupUserID for the precedence (Discord guild → openline
// participant → group fallback). Individual senderID is preserved in
// InboundMessage for pairing/dedup/mention gating regardless of scope.
userID := deriveGroupUserID(msg, peerKind)
// Persist friendly names from channel metadata into session + user profile.
sessionMeta := extractSessionMetadata(msg, peerKind)
@@ -133,7 +153,10 @@ func processNormalMessage(
// Also collect group chat as a contact (for group permission management / merge).
// Group IDs (e.g., Telegram "-100456") differ from user IDs — no UNIQUE conflict.
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
// Discord persists raw entity names plus hierarchy display metadata in its
// adapter. Do not overwrite that raw contact title with ChatTitle, which
// is intentionally parent-qualified for prompt/display context.
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" && channelType != channels.TypeDiscord {
groupTitle := msg.Metadata[tools.MetaChatTitle] // Telegram: message.Chat.Title
deps.ContactCollector.EnsureContact(ctx, channelType, msg.Channel, msg.ChatID, "", groupTitle, "", "group", "group", "", "")
}
@@ -234,6 +257,22 @@ func processNormalMessage(
}
}
// Forward Bitrix24-specific routing keys so Send() can:
// 1. Branch v2 public vs v1 whisper (bitrix_visibility)
// 2. Set fields.replyId on v2 public reply (bitrix_message_id)
// CopyFinalRoutingMeta is channel-agnostic and doesn't include these.
if v := msg.Metadata[bitrix24.MetaKeyVisibility]; v != "" {
outMeta[bitrix24.MetaKeyVisibility] = v
}
if v := msg.Metadata[bitrix24.MetaKeyMessageID]; v != "" {
outMeta[bitrix24.MetaKeyMessageID] = v
}
// Openline sender tag captured on inbound → Send() prepends it to the reply
// so the connector routes the answer back to the right external user.
if v := msg.Metadata[bitrix24.MetaKeySenderPrefix]; v != "" {
outMeta[bitrix24.MetaKeySenderPrefix] = v
}
// Register run with channel manager for streaming/reaction event forwarding.
// Use localKey (composite key with topic suffix) so streaming/reaction events
// route to the correct per-topic state in the channel.
@@ -401,11 +440,16 @@ func processNormalMessage(
}
}
inboundMessage := msg.Content
// Inject tenant context from channel instance so all store queries are tenant-scoped.
if msg.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, msg.TenantID)
}
gate := applyTeamWorkGateForInbound(ctx, deps, msg, sessionKey, agentID, peerKind, agentLoop.UUID(), skillFilter, agentLoop.Provider(), agentLoop.Model())
inboundMessage = gate.Message
// Inject post-turn dispatch tracker so team task creates are deferred.
ptd := tools.NewPendingTeamDispatch()
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
@@ -441,7 +485,7 @@ func processNormalMessage(
// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
outCh := deps.Sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Message: inboundMessage,
Media: reqMedia,
ForwardMedia: fwdMedia,
Channel: msg.Channel,
@@ -449,22 +493,24 @@ func processNormalMessage(
// Forward Bitrix24 portal domain from channel metadata so the
// system prompt can teach the LLM the correct entity URL host.
// Empty for non-bitrix24 channels — section is skipped downstream.
BitrixPortalDomain: msg.Metadata["bitrix_portal"],
ChatTitle: msg.Metadata[tools.MetaChatTitle],
ChatID: msg.ChatID,
WorkspaceChatID: msg.ChatID,
PeerKind: peerKind,
LocalKey: msg.Metadata["local_key"],
UserID: userID,
SenderID: effectiveSenderID,
Role: effectiveRole,
SenderName: resolveSenderName(msg),
RunID: runID,
Stream: providerStream,
HistoryLimit: msg.HistoryLimit,
ToolAllow: msg.ToolAllow,
ExtraSystemPrompt: extraPrompt,
SkillFilter: skillFilter,
BitrixPortalDomain: msg.Metadata["bitrix_portal"],
ChatTitle: msg.Metadata[tools.MetaChatTitle],
ChatID: msg.ChatID,
WorkspaceChatID: msg.ChatID,
PeerKind: peerKind,
LocalKey: msg.Metadata["local_key"],
UserID: userID,
SenderID: effectiveSenderID,
Role: effectiveRole,
SenderName: resolveSenderName(msg),
RunID: runID,
Stream: providerStream,
HistoryLimit: msg.HistoryLimit,
ToolAllow: msg.ToolAllow,
TelegramManagerPermissions: msg.TelegramManagerPermissions,
ExtraSystemPrompt: extraPrompt,
TeamWorkDirective: gate.Directive,
SkillFilter: skillFilter,
}, scheduler.ScheduleOpts{
MaxConcurrent: maxConcurrent,
})
@@ -529,9 +575,14 @@ func processNormalMessage(
return
}
// Suppress empty/NO_REPLY responses (matching TS normalize-reply.ts).
// Still publish an empty outbound so channels can clean up placeholder/thinking indicators.
if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) {
// Suppress silent text only when the result has no media. A tool or
// delegate may legitimately return NO_REPLY with an attached artifact;
// that must continue as a media-only outbound message.
resultContent, shouldDeliver := normalizeAgentOutboundContent(
outcome.Result.Content,
len(outcome.Result.Media),
)
if !shouldDeliver {
slog.Info("inbound: suppressed silent/empty reply",
"channel", channel,
"chat_id", chatID,
@@ -569,7 +620,7 @@ func processNormalMessage(
// Sanitize voice agent replies: replace technical errors with user-friendly fallback.
replyContent := voiceguard.SanitizeReply(
deps.Cfg.Channels.Telegram.VoiceAgentID, agentKey,
channel, peerKind, inboundContent, outcome.Result.Content,
channel, peerKind, inboundContent, resultContent,
deps.Cfg.Channels.Telegram.AudioGuardFallbackTranscript,
deps.Cfg.Channels.Telegram.AudioGuardFallbackNoTranscript,
deps.Cfg.Channels.Telegram.AudioGuardErrorMarkers,
@@ -608,7 +659,7 @@ func processNormalMessage(
if deps.TeamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
go autoSetFollowup(ctx, deps.TeamStore, deps.AgentStore, agentKey, channel, chatID, replyContent)
}
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig())
}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, inboundMessage, outMeta, blockReply, chatBehavior, channelStream, ptd, msg.TenantID, agentLoop.UUID(), agentLoop.OtherConfig())
}
func buildDeliveryRuntime(ctx context.Context, deps *ConsumerDeps, agentLoop agent.Agent, behavior channels.ResolvedChatBehavior, msg bus.InboundMessage, userID, peerKind, channelType, agentKey string) channels.DeliveryRuntime {
@@ -698,3 +749,32 @@ func isSafeBitrixEntityToken(s string, maxLen int) bool {
}
return true
}
// deriveGroupUserID computes the per-message scope userID used for context
// files, memory, traces, and seeding. Direct messages keep msg.UserID. Group
// messages pick a synthetic scope, in precedence order:
//
// 1. Discord guild member: "guild:{guildID}:user:{senderID}" — per-user across
// every channel in the same server.
// 2. Openline participant: the per-participant id minted by bitrix24/handle.go
// ("openlines:{instance}:{chat}:{uid}") when a connector relayed a customer
// message carrying a stable uid — so each external person gets their own
// USER.md / memory instead of collapsing into the shared connector proxy.
// Absent for legacy/name-only/operator messages → falls through.
// 3. Group fallback: "group:{channel}:{chatID}" — shared by everyone in the chat.
//
// The individual senderID stays on InboundMessage for pairing / dedup / mention
// gating regardless of which scope is chosen.
func deriveGroupUserID(msg bus.InboundMessage, peerKind string) string {
if peerKind != string(sessions.PeerGroup) || msg.ChatID == "" {
return msg.UserID
}
switch {
case msg.Metadata["guild_id"] != "" && msg.SenderID != "":
return fmt.Sprintf("guild:%s:user:%s", msg.Metadata["guild_id"], msg.SenderID)
case msg.Metadata[bitrix24.MetaKeyParticipantUserID] != "":
return msg.Metadata[bitrix24.MetaKeyParticipantUserID]
default:
return fmt.Sprintf("group:%s:%s", msg.Channel, msg.ChatID)
}
}
+252
View File
@@ -1,10 +1,18 @@
package cmd
import (
"context"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/bitrix24"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// TestIsSafeBitrixEntityToken pins the validation contract for webhook-sourced
@@ -44,6 +52,130 @@ func TestIsSafeBitrixEntityToken(t *testing.T) {
}
}
// TestDeriveGroupUserID pins the group-scope userID precedence: Discord guild
// member → openline participant → group fallback, with direct messages passing
// msg.UserID through untouched. The openline participant branch is what gives
// each Zalo customer its own per-person scope instead of the shared connector
// proxy.
func TestDeriveGroupUserID(t *testing.T) {
const group = string(sessions.PeerGroup)
const direct = string(sessions.PeerDirect)
cases := []struct {
name string
msg bus.InboundMessage
peerKind string
want string
}{
{
name: "openline participant overrides group fallback",
msg: bus.InboundMessage{
Channel: "zalo_ol",
ChatID: "chat4878",
SenderID: "openlines:tamgiac:chat4878:111222",
UserID: "openlines:tamgiac:chat4878:111222",
Metadata: map[string]string{
bitrix24.MetaKeyParticipantUserID: "openlines:tamgiac:chat4878:111222",
},
},
peerKind: group,
want: "openlines:tamgiac:chat4878:111222",
},
{
name: "no participant id falls back to group-level",
msg: bus.InboundMessage{
Channel: "zalo_ol",
ChatID: "chat4878",
UserID: "960",
},
peerKind: group,
want: "group:zalo_ol:chat4878",
},
{
name: "empty participant id (parse-fail degrade) falls back to group-level",
msg: bus.InboundMessage{
Channel: "zalo_ol",
ChatID: "chat4878",
UserID: "960",
Metadata: map[string]string{bitrix24.MetaKeyParticipantUserID: ""},
},
peerKind: group,
want: "group:zalo_ol:chat4878",
},
{
name: "discord guild takes precedence over participant id",
msg: bus.InboundMessage{
Channel: "discord",
ChatID: "chan-1",
SenderID: "u-9",
Metadata: map[string]string{
"guild_id": "g-1",
bitrix24.MetaKeyParticipantUserID: "openlines:x:y:z",
},
},
peerKind: group,
want: "guild:g-1:user:u-9",
},
{
name: "direct message passes UserID through untouched",
msg: bus.InboundMessage{
Channel: "zalo_ol",
ChatID: "chat4878",
UserID: "42",
Metadata: map[string]string{bitrix24.MetaKeyParticipantUserID: "openlines:x:y:z"},
},
peerKind: direct,
want: "42",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := deriveGroupUserID(tc.msg, tc.peerKind); got != tc.want {
t.Errorf("deriveGroupUserID() = %q; want %q", got, tc.want)
}
})
}
}
func TestResolveGroupDisplayTitleFallsBackThroughChannelManager(t *testing.T) {
manager := channels.NewManager(nil)
manager.RegisterChannel("discord-main", &consumerDisplayTitleChannel{
consumerTestChannel: consumerTestChannel{name: "discord-main", channelType: channels.TypeDiscord},
title: "launch-thread / product-planning",
})
if got := resolveGroupDisplayTitle(context.Background(), manager, "discord-main", "thread-1", string(sessions.PeerGroup), ""); got != "launch-thread / product-planning" {
t.Fatalf("resolved title = %q, want qualified title", got)
}
if got := resolveGroupDisplayTitle(context.Background(), manager, "discord-main", "thread-1", string(sessions.PeerGroup), "already present"); got != "already present" {
t.Fatalf("metadata title = %q, want original value", got)
}
}
func TestResolveInboundChatTitleAvoidsLiveLookupForExternalMessage(t *testing.T) {
manager := channels.NewManager(nil)
channel := &consumerDisplayTitleChannel{
consumerTestChannel: consumerTestChannel{name: "discord-main", channelType: channels.TypeDiscord},
title: "launch-thread / product-planning",
}
manager.RegisterChannel("discord-main", channel)
external := bus.InboundMessage{Channel: "discord-main", ChatID: "thread-1", SenderID: "user-1"}
if got := resolveInboundChatTitle(context.Background(), manager, external, string(sessions.PeerGroup)); got != "" {
t.Fatalf("external title = %q, want no live fallback", got)
}
if channel.calls != 0 {
t.Fatalf("external message invoked display resolver %d times", channel.calls)
}
internal := bus.InboundMessage{Channel: "discord-main", ChatID: "thread-1", SenderID: "system:ticker"}
if got := resolveInboundChatTitle(context.Background(), manager, internal, string(sessions.PeerGroup)); got != channel.title {
t.Fatalf("internal title = %q, want %q", got, channel.title)
}
if channel.calls != 1 {
t.Fatalf("internal message invoked display resolver %d times, want 1", channel.calls)
}
}
func TestResolveSenderNameReadsWhatsAppUserName(t *testing.T) {
got := resolveSenderName(bus.InboundMessage{
Metadata: map[string]string{
@@ -65,3 +197,123 @@ func TestResolveSenderNameTruncatesLongMetadata(t *testing.T) {
t.Fatalf("resolveSenderName() length = %d, want 100", len([]rune(got)))
}
}
func TestResolveAgentRouteForInbound_FallsBackToDBDefaultAgent(t *testing.T) {
cfg := &config.Config{}
got := resolveAgentRouteForInbound(context.Background(), cfg, defaultAgentGetterStub{
agent: &store.AgentData{AgentKey: "co-assistant"},
}, "co-assistant-2-0", "channel-1", string(sessions.PeerDirect))
if got != "co-assistant" {
t.Fatalf("resolveAgentRouteForInbound() = %q, want DB default agent key", got)
}
}
func TestResolveAgentRouteForInbound_BindingWinsOverDBDefault(t *testing.T) {
cfg := &config.Config{
Bindings: []config.AgentBinding{{
AgentID: "bound-agent",
Match: config.BindingMatch{
Channel: "co-assistant-2-0",
},
}},
}
got := resolveAgentRouteForInbound(context.Background(), cfg, defaultAgentGetterStub{
agent: &store.AgentData{AgentKey: "co-assistant"},
}, "co-assistant-2-0", "channel-1", string(sessions.PeerDirect))
if got != "bound-agent" {
t.Fatalf("resolveAgentRouteForInbound() = %q, want binding agent", got)
}
}
func TestProcessNormalMessage_AgentLookupFailurePublishesExternalCleanup(t *testing.T) {
msgBus := bus.New()
channelMgr := channels.NewManager(msgBus)
channelMgr.RegisterChannel("discord-prod", consumerTestChannel{
name: "discord-prod",
channelType: channels.TypeDiscord,
running: true,
})
metadata := map[string]string{
"message_id": "discord-message-1",
"placeholder_key": "discord-message-1",
}
processNormalMessage(context.Background(), bus.InboundMessage{
Channel: "discord-prod",
SenderID: "user-1",
ChatID: "channel-1",
Content: "hello",
PeerKind: string(sessions.PeerDirect),
AgentID: "missing-agent",
Metadata: metadata,
}, &ConsumerDeps{
Cfg: &config.Config{},
Agents: agent.NewRouter(),
ChannelMgr: channelMgr,
MsgBus: msgBus,
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound cleanup message")
}
if got.Content != "" {
t.Fatalf("outbound content = %q, want empty cleanup for external Discord channel", got.Content)
}
if got.Channel != "discord-prod" || got.ChatID != "channel-1" {
t.Fatalf("outbound route = %s/%s, want discord-prod/channel-1", got.Channel, got.ChatID)
}
if got.Metadata["placeholder_key"] != "discord-message-1" {
t.Fatalf("placeholder_key = %q, want metadata preserved", got.Metadata["placeholder_key"])
}
}
type consumerTestChannel struct {
name string
channelType string
running bool
}
type consumerDisplayTitleChannel struct {
consumerTestChannel
title string
calls int
}
func (c *consumerDisplayTitleChannel) ResolveGroupDisplayTitle(context.Context, string) (string, error) {
c.calls++
return c.title, nil
}
func (c consumerTestChannel) Name() string { return c.name }
func (c consumerTestChannel) Type() string { return c.channelType }
func (c consumerTestChannel) Start(context.Context) error {
return nil
}
func (c consumerTestChannel) Stop(context.Context) error {
return nil
}
func (c consumerTestChannel) Send(context.Context, bus.OutboundMessage) error {
return nil
}
func (c consumerTestChannel) IsRunning() bool {
return c.running
}
func (c consumerTestChannel) IsAllowed(string) bool {
return true
}
type defaultAgentGetterStub struct {
agent *store.AgentData
err error
}
func (s defaultAgentGetterStub) GetDefault(context.Context) (*store.AgentData, error) {
if s.err != nil {
return nil, s.err
}
return s.agent, nil
}
+16
View File
@@ -0,0 +1,16 @@
package cmd
import "github.com/nextlevelbuilder/goclaw/internal/agent"
// normalizeAgentOutboundContent keeps silent text out of user-facing messages
// while allowing attached media to continue through channel delivery.
func normalizeAgentOutboundContent(content string, mediaCount int) (string, bool) {
isSilent := content == "" || agent.IsSilentReply(content)
if !isSilent {
return content, true
}
if mediaCount == 0 {
return "", false
}
return "", true
}
@@ -0,0 +1,83 @@
package cmd
import (
"context"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// A teammate run must ask the provider to stream. Without it a slow reasoning
// model holds a silent connection for the whole generation and
// ResponseHeaderTimeout kills the request before a single token is produced.
//
// The same test pins the other half of the contract: streaming here is for
// connection liveness only. The run is deliberately never registered with the
// channel manager, so HandleAgentEvent drops its chunks and nothing reaches a
// user incrementally — the task result keeps coming from the final RunResult.
func TestHandleTeammateMessageSchedulesStreamedRun(t *testing.T) {
var gotReq agent.RunRequest
ran := make(chan struct{})
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
MaxConcurrent: 1,
},
func(_ context.Context, req agent.RunRequest) (*agent.RunResult, error) {
gotReq = req
close(ran)
return &agent.RunResult{Content: "member deliverable"}, nil
},
)
defer sched.Stop()
channelMgr := channels.NewManager(nil)
deps := &ConsumerDeps{
Cfg: &config.Config{},
Sched: sched,
ChannelMgr: channelMgr,
}
msg := bus.InboundMessage{
Channel: tools.ChannelSystem,
SenderID: "teammate:dashboard",
AgentID: "coder",
Content: "[Assigned task #1 (id: 00000000-0000-0000-0000-000000000001)]: build something",
Metadata: map[string]string{
tools.MetaOriginChannel: "telegram",
tools.MetaOriginChatID: "12345",
tools.MetaFromAgent: "brain",
tools.MetaToAgent: "coder",
},
}
if !handleTeammateMessage(context.Background(), msg, deps) {
t.Fatal("handleTeammateMessage() = false, want true for a teammate: message on the system channel")
}
select {
case <-ran:
case <-time.After(5 * time.Second):
t.Fatal("teammate run was never scheduled")
}
if !gotReq.Stream {
t.Error("teammate run requested a non-streamed provider call: a slow model then holds a silent " +
"connection for the whole generation until ResponseHeaderTimeout kills it")
}
if delivered, last := channelMgr.InterimDeliverySnapshot(gotReq.RunID); delivered != 0 || last != "" {
t.Errorf("teammate run is registered for channel delivery (delivered=%d, last=%q); "+
"streamed chunks would reach a user incrementally", delivered, last)
}
}
+155 -39
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -12,6 +13,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/cronexec"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,12 +27,39 @@ import (
// Safe because cron jobs only fire after Start(), well after this is set.
var cronHeartbeatWakeFn func(agentID string)
func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config, channelMgr *channels.Manager, sessionMgr store.SessionStore, agentStore store.AgentStore) func(job *store.CronJob) (*store.CronJobResult, error) {
// cronCLISessionReset clears the Claude CLI on-disk session (.jsonl + CLAUDE.md)
// for a session key, mirroring the sessions.reset RPC. Indirected through a var
// so the stateless-reset behavior can be unit-tested without filesystem effects.
var cronCLISessionReset = providers.ResetCLISession
// cronTenantContext scopes a context to the job's tenant. It sets BOTH the
// tenant ID and the tenant SLUG: tenant-scoped filesystem paths
// (skills-store, workspace, media via config.TenantScopedDir) key off the
// slug, and resolve to an id-based path when the slug is absent — a different
// directory than where HTTP/WS upload materialized the files. Without the
// slug, a cron agent turn sees NONE of its tenant's managed skills. tenantStore
// may be nil (older wiring) — then only the tenant ID is set, preserving prior
// behavior. Master tenant needs no slug (TenantScopedDir returns the base).
func cronTenantContext(ctx context.Context, tenantStore store.TenantStore, tenantID uuid.UUID) context.Context {
ctx = store.WithTenantID(ctx, tenantID)
if tenantStore == nil || tenantID == uuid.Nil || tenantID == store.MasterTenantID {
return ctx
}
tenant, err := tenantStore.GetTenant(ctx, tenantID)
if err != nil || tenant == nil || tenant.Slug == "" {
slog.Warn("cron: could not resolve tenant slug; tenant-scoped skills/workspace may be invisible",
"tenant_id", tenantID, "error", err)
return ctx
}
return store.WithTenantSlug(ctx, tenant.Slug)
}
func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg *config.Config, channelMgr *channels.Manager, sessionMgr store.SessionStore, agentStore store.AgentStore, tenantStore store.TenantStore, providerStore store.ProviderStore, providerReg *providers.Registry) func(job *store.CronJob) (*store.CronJobResult, error) {
return func(job *store.CronJob) (*store.CronJobResult, error) {
agentID := job.AgentID
if agentID == "" && agentStore != nil {
// Resolve real default agent from DB instead of using literal "default" string.
tenantCtx := store.WithTenantID(context.Background(), job.TenantID)
tenantCtx := cronTenantContext(context.Background(), tenantStore, job.TenantID)
if defaultAgent, err := agentStore.GetDefault(tenantCtx); err == nil {
agentID = defaultAgent.AgentKey
} else {
@@ -40,7 +70,7 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
} else if id, err := uuid.Parse(agentID); err == nil && agentStore != nil {
// Resolve agentKey from UUID so session key uses agentKey
// (consistent with chat/WS/team paths, fixes cache invalidation mismatch).
cronCtx := store.WithTenantID(context.Background(), job.TenantID)
cronCtx := cronTenantContext(context.Background(), tenantStore, job.TenantID)
if ag, err := agentStore.GetByID(cronCtx, id); err == nil {
agentID = ag.AgentKey
}
@@ -61,6 +91,12 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
// Resolve channel type for system prompt context.
channelType := resolveChannelType(channelMgr, channel)
// Deterministic command payload: run the shell command in-process WITHOUT
// an LLM/agent turn (zero model tokens). Gated by cron.command_enabled.
if job.Payload.IsCommand() {
return runCommandCronJob(cfg, job, tenantStore, msgBus, peerKind)
}
// Build cron context so the agent knows delivery target and requester.
var extraPrompt string
if job.Deliver && job.DeliverChannel != "" && job.DeliverTo != "" {
@@ -83,21 +119,50 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
jobTimeout := cfg.Cron.JobTimeoutDuration()
cronCtx, cancelCron := context.WithTimeout(context.Background(), jobTimeout)
defer cancelCron()
cronCtx = store.WithTenantID(cronCtx, job.TenantID)
cronCtx = cronTenantContext(cronCtx, tenantStore, job.TenantID)
if job.Payload.CredentialUserID != "" {
cronCtx = store.WithCredentialUserID(cronCtx, job.Payload.CredentialUserID)
}
chatTitle := resolveGroupDisplayTitle(cronCtx, channelMgr, channel, job.DeliverTo, peerKind, "")
// Reset the session before each STATELESS cron run so the run starts fresh:
// no carried-over history (the whole point of stateless — saves tokens) and
// no tool errors from a previous run polluting the context (#294). Clear BOTH
// layers — the goclaw session store AND the Claude CLI's own on-disk session —
// because a claude-cli agent resumes its .jsonl by a deterministic per-key
// UUID and would otherwise replay the full accumulated history every run
// regardless of this flag (i.e. "stateless" had no effect on what the model
// actually sees). Stateful jobs (stateless=false) intentionally keep their
// session across runs.
//
// NOTE: this condition was previously `!job.Stateless`, which inverted the
// flag — stateless jobs accumulated unbounded history while stateful jobs were
// wiped each run.
if job.Stateless {
if sessionMgr != nil {
sessionMgr.Reset(cronCtx, sessionKey)
sessionMgr.Save(cronCtx, sessionKey)
}
cronCLISessionReset("", sessionKey)
}
// Resolve per-job provider/model override (mirrors heartbeat). Unset → agent default.
var providerOverride providers.Provider
if job.ProviderID != nil && providerStore != nil && providerReg != nil {
if provData, perr := providerStore.GetProvider(cronCtx, *job.ProviderID); perr == nil {
if prov, gerr := providerReg.GetForTenant(job.TenantID, provData.Name); gerr == nil {
providerOverride = prov
} else {
slog.Warn("cron.provider_not_in_registry", "job", job.ID, "provider_id", job.ProviderID, "error", gerr)
}
} else {
slog.Warn("cron.provider_not_found", "job", job.ID, "provider_id", job.ProviderID, "error", perr)
}
}
var modelOverride string
if job.Model != nil {
modelOverride = *job.Model
// Stateless jobs explicitly reset their session before each run — they
// carry no history between executions, matching the "Stateless" UI label
// and statelessHelp ("each run starts fresh without loading previous
// messages"). Stateful (non-stateless) jobs keep prior turns to enable
// multi-run dialog.
// Save() persists the empty session to DB so stale data won't reload
// after restart (#294).
if job.Stateless && sessionMgr != nil {
sessionMgr.Reset(cronCtx, sessionKey)
sessionMgr.Save(cronCtx, sessionKey)
}
// Schedule through cron lane — scheduler handles agent resolution and concurrency
@@ -107,10 +172,13 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
Channel: channel,
ChannelType: channelType,
ChatID: job.DeliverTo,
ChatTitle: chatTitle,
PeerKind: peerKind,
UserID: job.UserID,
RunID: fmt.Sprintf("cron:%s", job.ID),
Stream: false,
ModelOverride: modelOverride,
ProviderOverride: providerOverride,
ExtraSystemPrompt: extraPrompt,
TraceName: fmt.Sprintf("Cron [%s] - %s", job.Name, agentID),
TraceTags: []string{"cron"},
@@ -130,31 +198,7 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
result := outcome.Result
// If job wants delivery to a channel, send the agent response to the target chat.
if job.Deliver && job.DeliverChannel != "" && job.DeliverTo != "" {
if cronOutputContainsNoReplySentinel(result.Content) {
slog.Info("cron: suppressed delivery because output contained NO_REPLY",
"job_id", job.ID,
"job_name", job.Name,
"channel", job.DeliverChannel,
"to", job.DeliverTo,
"content_len", len(result.Content),
)
} else {
outMsg := bus.OutboundMessage{
Channel: job.DeliverChannel,
ChatID: job.DeliverTo,
Content: result.Content,
}
if peerKind == "group" {
outMsg.Metadata = map[string]string{"group_id": job.DeliverTo}
}
appendMediaToOutbound(&outMsg, result.Media)
msgBus.PublishOutbound(outMsg)
}
} else if job.Deliver {
slog.Warn("cron: delivery configured but channel/chatID missing — output discarded",
"job_id", job.ID, "job_name", job.Name, "channel", job.DeliverChannel, "to", job.DeliverTo)
}
deliverCronOutput(msgBus, job, result.Content, result.Media, peerKind)
cronResult := &store.CronJobResult{
Content: result.Content,
@@ -174,6 +218,78 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
}
}
// deliverCronOutput publishes a cron job's output to the configured delivery
// channel, honoring the NO_REPLY sentinel. Shared by the agent-turn and the
// deterministic command-payload paths.
func deliverCronOutput(msgBus *bus.MessageBus, job *store.CronJob, content string, media []agent.MediaResult, peerKind string) {
if job.Deliver && job.DeliverChannel != "" && job.DeliverTo != "" {
if cronOutputContainsNoReplySentinel(content) {
slog.Info("cron: suppressed delivery because output contained NO_REPLY",
"job_id", job.ID,
"job_name", job.Name,
"channel", job.DeliverChannel,
"to", job.DeliverTo,
"content_len", len(content),
)
return
}
outMsg := bus.OutboundMessage{
Channel: job.DeliverChannel,
ChatID: job.DeliverTo,
Content: content,
}
if peerKind == "group" {
outMsg.Metadata = map[string]string{"group_id": job.DeliverTo}
}
appendMediaToOutbound(&outMsg, media)
msgBus.PublishOutbound(outMsg)
return
}
if job.Deliver {
slog.Warn("cron: delivery configured but channel/chatID missing — output discarded",
"job_id", job.ID, "job_name", job.Name, "channel", job.DeliverChannel, "to", job.DeliverTo)
}
}
// runCommandCronJob executes a deterministic command-payload cron job in-process
// without an LLM turn. On success it delivers the command output (stdout, else
// stderr) like an agent turn. On failure it returns an error so the run is
// recorded as "error" and retried per cron.max_retries — failures are NOT
// delivered, mirroring the agent path where only successful output is announced.
func runCommandCronJob(cfg *config.Config, job *store.CronJob, tenantStore store.TenantStore, msgBus *bus.MessageBus, peerKind string) (*store.CronJobResult, error) {
if !cfg.Cron.CommandEnabled {
return nil, fmt.Errorf("cron command payloads are disabled; set cron.command_enabled=true to allow them")
}
spec := job.Payload.Command
if err := store.ValidateCronCommandSpec(spec); err != nil {
return nil, err
}
cmdTimeout := cfg.Cron.CommandTimeoutDuration()
if spec.TimeoutSeconds > 0 {
cmdTimeout = time.Duration(spec.TimeoutSeconds) * time.Second
}
// The job timeout is a hard ceiling above the per-command timeout.
ctx, cancel := context.WithTimeout(cronTenantContext(context.Background(), tenantStore, job.TenantID), cfg.Cron.JobTimeoutDuration())
defer cancel()
res := cronexec.Run(ctx, cronexec.Spec{
Argv: spec.Argv,
Cwd: spec.Cwd,
Env: spec.Env,
Input: spec.Input,
Timeout: cmdTimeout,
NoOutputTimeout: time.Duration(spec.NoOutputTimeoutSeconds) * time.Second,
OutputMaxBytes: spec.OutputMaxBytes,
})
if res.Status != cronexec.StatusOK {
return nil, res.Err
}
deliverCronOutput(msgBus, job, res.Summary, nil, peerKind)
return &store.CronJobResult{Content: res.Summary}, nil
}
func cronOutputContainsNoReplySentinel(content string) bool {
return agent.IsSilentReply(content)
}
+104
View File
@@ -0,0 +1,104 @@
//go:build !windows
package cmd
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func commandCronConfig(enabled bool) *config.Config {
c := &config.Config{}
c.Cron.CommandEnabled = enabled
return c
}
func commandCronJob(spec *store.CronCommandSpec, deliver bool) *store.CronJob {
job := &store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "probe",
AgentID: "ops",
UserID: "user-1",
Payload: store.CronPayload{Kind: store.CronPayloadKindCommand, Command: spec},
}
if deliver {
job.Deliver = true
job.DeliverChannel = "telegram"
job.DeliverTo = "chat-1"
}
return job
}
// A command payload must be refused unless cron.command_enabled is set.
func TestCronJobHandler_CommandDisabled(t *testing.T) {
handler := makeCronJobHandler(nil, nil, commandCronConfig(false), nil, nil, nil, nil, nil, nil)
if _, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo hi"}}, false)); err == nil {
t.Fatal("expected error when cron.command_enabled is false")
}
}
// A successful command runs with zero model tokens and its stdout is delivered.
func TestCronJobHandler_CommandSuccessDelivers(t *testing.T) {
mb := bus.New()
defer mb.Close()
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "printf hello"}}, true))
if err != nil {
t.Fatalf("command cron returned error: %v", err)
}
if result == nil || result.Content != "hello" {
t.Fatalf("result = %#v, want content hello", result)
}
if result.InputTokens != 0 || result.OutputTokens != 0 {
t.Errorf("command cron must report zero tokens, got in=%d out=%d", result.InputTokens, result.OutputTokens)
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
got, ok := mb.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound delivery of command output")
}
if got.Content != "hello" || got.Channel != "telegram" || got.ChatID != "chat-1" {
t.Fatalf("outbound = %#v, want telegram/chat-1/hello", got)
}
}
// A non-zero exit returns an error (recorded as a failed run) and is NOT
// delivered — only successful output is announced.
func TestCronJobHandler_CommandFailureNotDelivered(t *testing.T) {
mb := bus.New()
defer mb.Close()
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo boom 1>&2; exit 3"}}, true))
if err == nil {
t.Fatal("expected error for non-zero command exit")
}
if result != nil {
t.Fatalf("failed command should return nil result, got %#v", result)
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if got, ok := mb.SubscribeOutbound(ctx); ok {
t.Fatalf("failed command must not deliver, got %#v", got)
}
}
// An empty argv is rejected before execution.
func TestCronJobHandler_CommandInvalidSpec(t *testing.T) {
handler := makeCronJobHandler(nil, nil, commandCronConfig(true), nil, nil, nil, nil, nil, nil)
if _, err := handler(commandCronJob(&store.CronCommandSpec{}, false)); err == nil {
t.Fatal("expected error for empty argv")
}
}
+191
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"fmt"
"testing"
"time"
@@ -9,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -41,6 +43,9 @@ func TestCronJobHandlerInjectsPayloadCredentialUserID(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
nil,
)
result, err := handler(&store.CronJob{
@@ -67,6 +72,53 @@ func TestCronJobHandlerInjectsPayloadCredentialUserID(t *testing.T) {
}
}
func TestCronJobHandlerResolvesGroupDisplayTitle(t *testing.T) {
var got agent.RunRequest
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{Mode: scheduler.QueueModeQueue, Cap: 1, MaxConcurrent: 1},
func(_ context.Context, req agent.RunRequest) (*agent.RunResult, error) {
got = req
return &agent.RunResult{Content: "ok"}, nil
},
)
defer sched.Stop()
msgBus := bus.New()
defer msgBus.Close()
manager := channels.NewManager(nil)
manager.RegisterChannel("discord-main", cronDisplayTitleChannel{consumerTestChannel: consumerTestChannel{name: "discord-main", channelType: channels.TypeDiscord}, title: "launch-thread / product-planning"})
handler := makeCronJobHandler(sched, msgBus, &config.Config{}, manager, nil, nil, nil, nil, nil)
if _, err := handler(&store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "thread-report",
AgentID: "reporter",
UserID: "guild:guild-1:user:user-1",
Deliver: true,
DeliverChannel: "discord-main",
DeliverTo: "thread-1",
Payload: store.CronPayload{Kind: "agent_turn", Message: "report"},
}); err != nil {
t.Fatalf("cron handler: %v", err)
}
if got.ChatID != "thread-1" {
t.Fatalf("chat ID = %q, want stable thread ID", got.ChatID)
}
if got.ChatTitle != "launch-thread / product-planning" {
t.Fatalf("chat title = %q, want qualified title", got.ChatTitle)
}
}
type cronDisplayTitleChannel struct {
consumerTestChannel
title string
}
func (c cronDisplayTitleChannel) ResolveGroupDisplayTitle(context.Context, string) (string, error) {
return c.title, nil
}
func TestCronOutputContainsNoReplySentinel(t *testing.T) {
tests := []struct {
name string
@@ -137,6 +189,9 @@ func TestCronJobHandlerSuppressesNoReplyDelivery(t *testing.T) {
nil,
nil,
nil,
nil,
nil,
nil,
)
result, err := handler(&store.CronJob{
@@ -179,3 +234,139 @@ func TestCronJobHandlerSuppressesNoReplyDelivery(t *testing.T) {
})
}
}
// fakeCronSessionStore records Reset calls. The embedded nil SessionStore
// satisfies the interface; the cron handler only calls Reset/Save.
type fakeCronSessionStore struct {
store.SessionStore
resetCount int
}
func (f *fakeCronSessionStore) Reset(context.Context, string) { f.resetCount++ }
func (f *fakeCronSessionStore) Save(context.Context, string) error { return nil }
// A stateless cron run must start fresh by clearing BOTH the goclaw session
// store and the Claude CLI on-disk session; a stateful run must keep both.
func TestCronJobHandler_StatelessResetsSession(t *testing.T) {
cases := []struct {
name string
stateless bool
wantReset bool
}{
{name: "stateless resets both layers", stateless: true, wantReset: true},
{name: "stateful keeps session", stateless: false, wantReset: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var cliResetKeys []string
orig := cronCLISessionReset
cronCLISessionReset = func(_, key string) { cliResetKeys = append(cliResetKeys, key) }
defer func() { cronCLISessionReset = orig }()
fakeStore := &fakeCronSessionStore{}
sched := scheduler.NewScheduler(
scheduler.DefaultLanes(),
scheduler.QueueConfig{
Mode: scheduler.QueueModeQueue,
Cap: 1,
Drop: scheduler.DropOld,
DebounceMs: 0,
MaxConcurrent: 1,
},
func(context.Context, agent.RunRequest) (*agent.RunResult, error) {
return &agent.RunResult{Content: "ok"}, nil
},
)
defer sched.Stop()
handler := makeCronJobHandler(sched, nil, &config.Config{}, nil, fakeStore, nil, nil, nil, nil)
if _, err := handler(&store.CronJob{
ID: uuid.NewString(),
TenantID: uuid.New(),
Name: "j",
AgentID: "reporter",
UserID: "user-1",
Stateless: tc.stateless,
Payload: store.CronPayload{Kind: "agent_turn", Message: "m"},
}); err != nil {
t.Fatalf("cron handler error: %v", err)
}
if gotStore := fakeStore.resetCount > 0; gotStore != tc.wantReset {
t.Errorf("session store reset called=%v, want %v", gotStore, tc.wantReset)
}
if gotCLI := len(cliResetKeys) > 0; gotCLI != tc.wantReset {
t.Errorf("CLI session reset called=%v, want %v", gotCLI, tc.wantReset)
}
})
}
}
// fakeTenantStore implements only GetTenant; embedding the interface satisfies
// the rest (calling any other method would nil-panic, which none of these tests do).
type fakeTenantStore struct {
store.TenantStore
byID map[uuid.UUID]*store.TenantData
err error
}
func (f *fakeTenantStore) GetTenant(_ context.Context, id uuid.UUID) (*store.TenantData, error) {
if f.err != nil {
return nil, f.err
}
return f.byID[id], nil
}
func TestCronTenantContext_InjectsSlugForNonMasterTenant(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ts := &fakeTenantStore{byID: map[uuid.UUID]*store.TenantData{
tid: {ID: tid, Slug: "family-pilot"},
}}
ctx := cronTenantContext(context.Background(), ts, tid)
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v", got, tid)
}
// The slug is what tenant-scoped skills-store/workspace paths key off; without
// it a cron agent turn sees none of its tenant's managed skills.
if got := store.TenantSlugFromContext(ctx); got != "family-pilot" {
t.Errorf("tenant slug = %q, want %q (skills-store would resolve to the wrong dir)", got, "family-pilot")
}
}
func TestCronTenantContext_MasterTenantNeedsNoSlug(t *testing.T) {
// Master tenant paths resolve to the base dir regardless of slug; the store
// must not even be consulted.
ts := &fakeTenantStore{err: fmt.Errorf("GetTenant must not be called for master")}
ctx := cronTenantContext(context.Background(), ts, store.MasterTenantID)
if got := store.TenantIDFromContext(ctx); got != store.MasterTenantID {
t.Errorf("tenant id = %v, want master", got)
}
}
func TestCronTenantContext_NilStore_TenantIDOnly(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ctx := cronTenantContext(context.Background(), nil, tid)
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v", got, tid)
}
if got := store.TenantSlugFromContext(ctx); got != "" {
t.Errorf("slug = %q, want empty when store is nil", got)
}
}
func TestCronTenantContext_LookupError_FallsBackToIDOnly(t *testing.T) {
tid := uuid.Must(uuid.NewV7())
ts := &fakeTenantStore{err: fmt.Errorf("db down")}
ctx := cronTenantContext(context.Background(), ts, tid)
if got := store.TenantSlugFromContext(ctx); got != "" {
t.Errorf("slug = %q, want empty on lookup error", got)
}
if got := store.TenantIDFromContext(ctx); got != tid {
t.Errorf("tenant id = %v, want %v (must still scope by id)", got, tid)
}
}
+54
View File
@@ -0,0 +1,54 @@
package cmd
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// buildAgentLinkRunRequest preserves the origin's authorization-bearing
// identity while keeping delegation on its internal delivery channel.
func buildAgentLinkRunRequest(req tools.DelegateRequest, sessionKey string) agent.RunRequest {
return agent.RunRequest{
RunID: uuid.New().String(),
SessionKey: sessionKey,
Message: req.Task,
UserID: req.UserID,
SenderID: req.SenderID,
Role: req.Role,
Channel: "delegate",
ChannelType: req.ChannelType,
ChatID: req.ChatID,
PeerKind: req.PeerKind,
RunKind: "delegate",
DelegationID: req.DelegationID,
ParentAgentID: req.FromAgentKey,
WorkspaceChannel: req.Channel,
WorkspaceChatID: req.ChatID,
DelegateInputsPath: req.DelegateInputsPath,
DelegateOutputsPath: req.DelegateOutputsPath,
}
}
// Agent Link artifacts are published from the exchange manifest. Raw delegate
// media paths point into B's ephemeral workspace and must never cross back to A.
func agentMediaToBusFiles(_ []agent.MediaResult) []bus.MediaFile { return nil }
func releaseDelegationSandbox(ctx context.Context, manager sandbox.Manager, sessionKey string) error {
if manager == nil {
return nil
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if err := manager.Release(cleanupCtx, sessionKey); err != nil {
return fmt.Errorf("delegation sandbox release failed")
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package cmd
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
type delegationReleaseManager struct {
released string
err error
}
func (m *delegationReleaseManager) Get(context.Context, string, string, *sandbox.Config, ...sandbox.GetOption) (sandbox.Sandbox, error) {
return nil, errors.New("not implemented")
}
func (m *delegationReleaseManager) Release(_ context.Context, key string) error {
m.released = key
return m.err
}
func (*delegationReleaseManager) ReleaseAll(context.Context) error { return nil }
func (*delegationReleaseManager) Stop() {}
func (*delegationReleaseManager) Stats() map[string]any { return nil }
func TestBuildAgentLinkRunRequestPreservesGroupAuthorizationScope(t *testing.T) {
req := tools.DelegateRequest{
FromAgentKey: "coordinator",
Task: "create the report",
DelegationID: uuid.NewString(),
UserID: "group:telegram:-100123",
SenderID: "386246614",
Role: "viewer",
Channel: "telegram-main",
ChannelType: "telegram",
ChatID: "-100123",
PeerKind: "group",
DelegateInputsPath: filepath.Join(t.TempDir(), "inputs"),
DelegateOutputsPath: filepath.Join(t.TempDir(), "outputs"),
}
got := buildAgentLinkRunRequest(req, "delegate:session")
if got.UserID != req.UserID || got.SenderID != req.SenderID || got.Role != req.Role {
t.Fatalf("authorization scope = (%q, %q, %q), want (%q, %q, %q)",
got.UserID, got.SenderID, got.Role, req.UserID, req.SenderID, req.Role)
}
if got.Channel != "delegate" || got.ChannelType != req.ChannelType {
t.Fatalf("channel = (%q, %q), want (delegate, %q)", got.Channel, got.ChannelType, req.ChannelType)
}
if got.ChatID != req.ChatID || got.PeerKind != req.PeerKind {
t.Fatalf("chat scope = (%q, %q), want (%q, %q)",
got.ChatID, got.PeerKind, req.ChatID, req.PeerKind)
}
if got.WorkspaceChannel != req.Channel || got.WorkspaceChatID != req.ChatID {
t.Fatalf("workspace scope = (%q, %q), want (%q, %q)",
got.WorkspaceChannel, got.WorkspaceChatID, req.Channel, req.ChatID)
}
if got.RunID == "" || got.RunKind != "delegate" || got.DelegationID != req.DelegationID {
t.Fatalf("delegate classification = %#v", got)
}
if got.DelegateInputsPath != req.DelegateInputsPath ||
got.DelegateOutputsPath != req.DelegateOutputsPath {
t.Fatalf("artifact runtime wiring = (%q, %q), want (%q, %q)",
got.DelegateInputsPath, got.DelegateOutputsPath,
req.DelegateInputsPath, req.DelegateOutputsPath)
}
if got.Media != nil {
t.Fatalf("run media = %#v, want exchange-only input delivery", got.Media)
}
}
func TestAgentMediaToBusFilesDiscardsEphemeralDelegateMedia(t *testing.T) {
got := agentMediaToBusFiles([]agent.MediaResult{{
Path: filepath.Join(t.TempDir(), "ephemeral.png"),
ContentType: "image/png",
}})
if got != nil {
t.Fatalf("media = %#v, want raw delegate media discarded", got)
}
}
func TestReleaseDelegationSandboxUsesExactSessionKey(t *testing.T) {
manager := &delegationReleaseManager{}
const sessionKey = "delegate:from:target:4fe8220f-07f1-4e64-a95c-b49ebc39db4a"
if err := releaseDelegationSandbox(context.Background(), manager, sessionKey); err != nil {
t.Fatalf("releaseDelegationSandbox: %v", err)
}
if manager.released != sessionKey {
t.Fatalf("released key = %q, want %q", manager.released, sessionKey)
}
}
func TestReleaseDelegationSandboxRedactsManagerFailure(t *testing.T) {
manager := &delegationReleaseManager{err: errors.New("/private/exchange/inputs remained mounted")}
err := releaseDelegationSandbox(context.Background(), manager, "delegate:session")
if err == nil {
t.Fatal("releaseDelegationSandbox unexpectedly succeeded")
}
if got := err.Error(); got != "delegation sandbox release failed" {
t.Fatalf("error = %q, want redacted release failure", got)
}
}
+4
View File
@@ -5,11 +5,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -27,6 +29,7 @@ type gatewayDeps struct {
pgStores *store.Stores
providerRegistry *providers.Registry
channelMgr *channels.Manager
channelMemorySvc *channelmemory.Service
agentRouter *agent.Router
toolsReg *tools.Registry
skillsLoader *skills.Loader // optional: enables skill creation in evolution approval
@@ -39,4 +42,5 @@ type gatewayDeps struct {
usageCapSvc *usagecaps.Service
audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler
ttsHandler *httpapi.TTSHandler // nil if TTS not configured; for hot-reload
teamWorkEmbedder memory.EmbeddingProvider
}
+6 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/sessions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -42,7 +43,7 @@ func startCronAndHeartbeat(
heartbeatMethods *methods.HeartbeatMethods,
) *heartbeat.Ticker {
// Start cron service with job handler (routes through scheduler's cron lane)
pgStores.Cron.SetOnJob(makeCronJobHandler(sched, msgBus, cfg, channelMgr, pgStores.Sessions, pgStores.Agents))
pgStores.Cron.SetOnJob(makeCronJobHandler(sched, msgBus, cfg, channelMgr, pgStores.Sessions, pgStores.Agents, pgStores.Tenants, pgStores.Providers, providerRegistry))
pgStores.Cron.SetOnEvent(func(event store.CronEvent) {
server.BroadcastEvent(*protocol.NewEvent(protocol.EventCron, event))
})
@@ -60,6 +61,10 @@ func startCronAndHeartbeat(
MsgBus: msgBus,
Sched: sched,
RunAgent: makeHeartbeatRunFn(sched),
ResolveGroupContext: func(ctx context.Context, channel, chatID string) (string, string) {
channelType := resolveChannelType(channelMgr, channel)
return channelType, resolveGroupDisplayTitle(ctx, channelMgr, channel, chatID, string(sessions.PeerGroup), "")
},
})
heartbeatTicker.SetOnEvent(func(event store.HeartbeatEvent) {
server.BroadcastEvent(*protocol.NewEvent(protocol.EventHeartbeat, event))
+25 -1
View File
@@ -1,8 +1,11 @@
package cmd
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
@@ -33,6 +36,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, stores.Tracing, defaultWorkspace, msgBus, summoner, isOwner)
agentsH.SetImportStores(stores.Memory, stores.KnowledgeGraph)
agentsH.SetDataDir(dataDir)
agentsH.SetDisabledToolsStore(stores.BuiltinToolTenantCfgs)
if stores.SecureCLI != nil && stores.SecureCLIGrants != nil {
if agentCreds, ok := stores.SecureCLI.(store.SecureCLIAgentCredentialStore); ok {
agentsH.SetGatewayOperatorBootstrap(stores.SecureCLI, stores.SecureCLIGrants, agentCreds, gatewayAddr)
@@ -65,7 +69,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
}
var mcpUserCredsH *httpapi.MCPUserCredentialsHandler
if stores != nil && stores.MCP != nil {
mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, stores.Tenants)
mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, stores.Tenants, msgBus)
}
if stores != nil && stores.ChannelInstances != nil {
@@ -134,6 +138,7 @@ func makeChannelMemoryService(stores *store.Stores, domainBus eventbus.DomainEve
return &channelmemory.Service{
Channels: stores.ChannelInstances,
Pending: stores.PendingMessages,
Contacts: stores.Contacts,
Extractions: stores.ChannelMemory,
Episodic: stores.Episodic,
EventBus: domainBus,
@@ -143,3 +148,22 @@ func makeChannelMemoryService(stores *store.Stores, domainBus eventbus.DomainEve
Redactor: channelmemory.NewRedactor(),
}
}
type channelMemoryContextProvider interface {
ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error)
}
func resolveChannelMemoryExtractionContext(ctx context.Context, mgr *channels.Manager, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
if mgr == nil || inst == nil || inst.Name == "" {
return channelmemory.ExtractionContext{}, nil
}
ch, ok := mgr.GetChannel(inst.Name)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
provider, ok := ch.(channelMemoryContextProvider)
if !ok {
return channelmemory.ExtractionContext{}, nil
}
return provider.ResolveMemoryExtractionContext(ctx, inst, group)
}
+30 -6
View File
@@ -16,6 +16,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/webhooks"
)
// httpHandlers bundles the results of wireHTTP() for passing to wireHTTPHandlersOnServer.
@@ -32,6 +33,7 @@ type httpHandlers struct {
secureCLI *httpapi.SecureCLIHandler
secureCLIGrant *httpapi.SecureCLIGrantHandler
mcpUserCreds *httpapi.MCPUserCredentialsHandler
mcpOAuth *httpapi.MCPOAuthHandler
}
// wireHTTPHandlersOnServer registers all HTTP handler objects onto the gateway server.
@@ -64,11 +66,14 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
d.server.SetMCPHandler(h.mcp)
}
if h.mcpUserCreds != nil {
if mcpPool != nil {
h.mcpUserCreds.SetPoolEvictor(mcpPool)
}
d.server.SetMCPUserCredentialsHandler(h.mcpUserCreds)
}
if h.mcpOAuth != nil {
if mcpPool != nil {
h.mcpOAuth.SetEvictor(mcpPool)
}
d.server.SetMCPOAuthHandler(h.mcpOAuth)
}
if h.channelInstances != nil {
d.server.SetChannelInstancesHandler(h.channelInstances)
}
@@ -136,6 +141,8 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
})
}
d.server.SetBrandingAssetsHandler(httpapi.NewBrandingAssetsHandler(d.dataDir))
// Usage analytics API
if d.pgStores.Snapshots != nil {
d.server.SetUsageHandler(httpapi.NewUsageHandler(d.pgStores.Snapshots, d.pgStores.UsageEvents, d.pgStores.DB))
@@ -179,9 +186,13 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// Webhook admin CRUD — available in all editions (Standard + Lite).
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06.
// adminH is captured so the test endpoint (POST /v1/webhooks/{id}/test) can be wired
// with the runtime invokers (llm/message handlers) once they are constructed below.
var adminH *httpapi.WebhooksAdminHandler
if d.pgStores != nil && d.pgStores.Webhooks != nil {
adminH := httpapi.NewWebhooksAdminHandler(
adminH = httpapi.NewWebhooksAdminHandler(
d.pgStores.Webhooks,
d.pgStores.WebhookCalls,
d.pgStores.Tenants,
d.msgBus,
)
@@ -191,13 +202,14 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// Webhook message endpoint — Standard edition only (channels required).
// Phase 05b: POST /v1/webhooks/message → sync channel send (text + optional media).
var msgH *httpapi.WebhookMessageHandler
if edition.Current().AllowsChannels() &&
d.pgStores != nil &&
d.pgStores.Webhooks != nil &&
d.pgStores.WebhookCalls != nil &&
d.pgStores.ChannelInstances != nil &&
d.channelMgr != nil {
msgH := httpapi.NewWebhookMessageHandler(
msgH = httpapi.NewWebhookMessageHandler(
d.channelMgr,
d.pgStores.ChannelInstances,
d.pgStores.WebhookCalls,
@@ -212,20 +224,29 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// Phase 06: POST /v1/webhooks/llm → sync agent run (≤30s) or async enqueue.
// LocalhostOnly enforcement is handled by WebhookAuthMiddleware at request time.
// lane=nil → handler self-creates internal default lane (4-slot).
var llmH *httpapi.WebhookLLMHandler
if d.pgStores != nil &&
d.pgStores.Webhooks != nil &&
d.pgStores.WebhookCalls != nil &&
d.agentRouter != nil {
llmH := httpapi.NewWebhookLLMHandler(
llmH = httpapi.NewWebhookLLMHandler(
d.agentRouter,
d.pgStores.WebhookCalls,
d.pgStores.Webhooks,
sharedWebhookLimiter, // K10: shared limiter
nil, // lane: nil → internal default (4-slot); configurable in future via cfg
webhooks.ResolveTimeoutSec(d.cfg.Gateway.WebhookSyncTimeoutSec),
webhooks.ResolveStream(d.cfg.Gateway.WebhookStream),
)
llmH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time
d.server.SetWebhookLLMHandler(llmH)
}
// Wire the admin test endpoint with runtime invokers. msgH is nil on Lite — the
// admin handler guards on nil and rejects message tests there.
if adminH != nil {
adminH.SetTesters(llmH, msgH)
}
}
// Allow browser-paired users to access HTTP APIs
@@ -325,6 +346,9 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
// Wire WS method — provider nil means each request resolves key via secretStore at HTTP layer.
// For WS, use same cache. Provider is resolved via secretStore at WS level in a future phase.
methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router())
// Wire the same cache + secret store into the CRUD MCP server (see
// internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()).
d.server.SetVoiceCache(voiceCache, secretStore)
}
// TTS synthesize endpoint — shares audio.Manager with setupTTS.
+137 -1
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
@@ -10,11 +11,14 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/bitrix24"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/heartbeat"
"github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tasks"
"github.com/nextlevelbuilder/goclaw/internal/tools"
@@ -32,9 +36,71 @@ type lifecycleDeps struct {
sandboxMgr sandbox.Manager
postTurn tools.PostTurnProcessor
subagentMgr *tools.SubagentManager
childRunAdmission *orchestration.ChildRunAdmission
consumerTeamStore store.TeamStore
auditCh chan bus.AuditEventPayload
sigCh chan os.Signal
terminateProcess func(int)
}
func drainChildRunsWithRetry(
admission *orchestration.ChildRunAdmission,
firstTimeout time.Duration,
retryTimeout time.Duration,
) error {
if admission == nil {
return nil
}
for attempt, timeout := range []time.Duration{firstTimeout, retryTimeout} {
drainCtx, drainCancel := context.WithTimeout(context.Background(), timeout)
err := admission.Close(drainCtx)
drainCancel()
if err == nil {
return nil
}
slog.Error("gateway: child-run drain attempt failed",
"attempt", attempt+1, "timeout", timeout, "error", err)
}
return fmt.Errorf("%w after retry", orchestration.ErrChildRunDrainTimeout)
}
func drainSubagentManagerWithRetry(
manager *tools.SubagentManager,
firstTimeout time.Duration,
retryTimeout time.Duration,
) error {
if manager == nil {
return nil
}
for attempt, timeout := range []time.Duration{firstTimeout, retryTimeout} {
drainCtx, drainCancel := context.WithTimeout(context.Background(), timeout)
err := manager.CloseContext(drainCtx)
drainCancel()
if err == nil {
return nil
}
slog.Error("gateway: subagent lifecycle drain attempt failed",
"attempt", attempt+1, "timeout", timeout, "error", err)
}
return fmt.Errorf("%w after retry", tools.ErrSubagentLifecycleDrainTimeout)
}
func drainDelegateToolWithRetry(
tool interface{ CloseContext(context.Context) error },
firstTimeout time.Duration,
retryTimeout time.Duration,
) error {
for attempt, timeout := range []time.Duration{firstTimeout, retryTimeout} {
drainCtx, drainCancel := context.WithTimeout(context.Background(), timeout)
err := tool.CloseContext(drainCtx)
drainCancel()
if err == nil {
return nil
}
slog.Error("gateway: delegate completion drain attempt failed",
"attempt", attempt+1, "timeout", timeout, "error", err)
}
return fmt.Errorf("delegate completion drain failed after retry")
}
// runLifecycle wires config-reload subscribers, starts consumers, task recovery,
@@ -147,7 +213,7 @@ func (d *gatewayDeps) runLifecycle(
d.channelMgr.SetContactCollector(contactCollector)
}
go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc, d.providerRegistry)
go consumeInboundMessages(ctx, d.msgBus, d.agentRouter, d.cfg, deps.sched, d.channelMgr, deps.consumerTeamStore, d.pgStores.AgentLinks, deps.quotaChecker, d.pgStores.Sessions, d.pgStores.Agents, contactCollector, deps.postTurn, deps.subagentMgr, d.usageCapSvc, d.providerRegistry, d.teamWorkEmbedder)
// Webhook callback worker — delivers async webhook_calls rows to receiver callback_url.
// Runs in both editions: Standard (PG, concurrency=4) and Lite (SQLite, concurrency=1).
@@ -172,6 +238,8 @@ func (d *gatewayDeps) runLifecycle(
webhooks.WorkerConfig{
WorkerConcurrency: workerConcurrency,
PerTenantConcurrency: 4,
AsyncAgentTimeout: webhooks.ResolveTimeoutSec(d.cfg.Gateway.WebhookAsyncTimeoutSec),
Stream: webhooks.ResolveStream(d.cfg.Gateway.WebhookStream),
},
)
// K6: decrypt raw secret for outbound HMAC signing using the same key as inbound verify.
@@ -195,6 +263,20 @@ func (d *gatewayDeps) runLifecycle(
// Broadcast shutdown event
d.server.BroadcastEvent(*protocol.NewEvent(protocol.EventShutdown, nil))
// Close child-run intake first. A drain timeout must terminate without
// unwinding runGateway defers under a still-live child callback.
if deps.childRunAdmission != nil {
if err := drainChildRunsWithRetry(deps.childRunAdmission, 30*time.Second, 5*time.Second); err != nil {
slog.Error("gateway: terminating after child-run drain failure", "error", err)
terminate := deps.terminateProcess
if terminate == nil {
terminate = os.Exit
}
terminate(1)
return
}
}
// Stop channels, cron, heartbeat, and task ticker
d.channelMgr.StopAll(context.Background())
d.pgStores.Cron.Stop()
@@ -213,6 +295,35 @@ func (d *gatewayDeps) runLifecycle(
close(deps.auditCh)
}
if delegate, ok := d.toolsReg.Get("delegate"); ok {
if closer, ok := delegate.(interface {
CloseContext(context.Context) error
}); ok {
if err := drainDelegateToolWithRetry(closer, 65*time.Second, 10*time.Second); err != nil {
slog.Error("gateway: terminating after delegate completion drain failure", "error", err)
terminate := deps.terminateProcess
if terminate == nil {
terminate = os.Exit
}
terminate(1)
return
}
} else if closer, ok := delegate.(interface{ Close() }); ok {
closer.Close()
}
}
if deps.subagentMgr != nil {
if err := drainSubagentManagerWithRetry(deps.subagentMgr, 65*time.Second, 10*time.Second); err != nil {
slog.Error("gateway: terminating after subagent lifecycle drain failure", "error", err)
terminate := deps.terminateProcess
if terminate == nil {
terminate = os.Exit
}
terminate(1)
return
}
}
// Close provider resources (e.g. Claude CLI temp files)
d.providerRegistry.Close()
@@ -257,6 +368,21 @@ func (d *gatewayDeps) runLifecycle(
slog.Info("webhook route mounted on gateway", "path", route.Path)
}
// Bitrix24: also claim+mount the shared webhook router directly, even if
// no channel_instances row has finished setup yet (bot_code/bot_name
// still empty, or the portal hasn't completed OAuth). /bitrix24/install
// is what completes portal OAuth — gating the route behind a
// fully-configured bot creates a deadlock where an admin can never
// finish installing the first portal on a fresh gateway. ClaimWebhookRoute
// is idempotent (first-claim-wins via CompareAndSwap), so this is a no-op
// if a bitrix24 Channel already claimed the route in the loop above.
if router := bitrix24.WebhookRouter(); router != nil {
if path, handler := router.ClaimWebhookRoute(); path != "" && handler != nil {
mux.Handle(path, handler)
slog.Info("webhook route mounted on gateway", "path", path)
}
}
tsCleanup := initTailscale(ctx, d.cfg, mux)
if tsCleanup != nil {
defer tsCleanup()
@@ -276,6 +402,16 @@ func (d *gatewayDeps) runLifecycle(
} else if !edition.Current().IsLimited() {
slog.Warn("security.cors_open: no allowed_origins configured — all WebSocket origins accepted. Set gateway.allowed_origins or GOCLAW_ALLOWED_ORIGINS for production")
}
if allowed, rejected := security.OperatorAllowlistStatus(); len(allowed) > 0 || len(rejected) > 0 {
if len(allowed) > 0 {
slog.Warn("security.ssrf_allowlist: SSRF protection relaxed for operator-configured ranges — tool- and admin-supplied URLs may reach them",
"env", security.SSRFAllowedCIDRsEnv, "allowed", allowed)
}
if len(rejected) > 0 {
slog.Warn("security.ssrf_allowlist_rejected: entries refused; cloud-metadata, multicast and unspecified ranges can never be allowlisted",
"env", security.SSRFAllowedCIDRsEnv, "rejected", rejected)
}
}
if err := d.server.Start(ctx); err != nil {
slog.Error("gateway error", "error", err)
+50 -16
View File
@@ -61,6 +61,8 @@ func wireExtras(
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
domainBus eventbus.DomainEventBus,
usageCapSvc *usagecaps.Service,
mcpOAuthProvider mcpbridge.OAuthTokenProvider, // nil = OAuth injection disabled
childRunAdmission *orchestration.ChildRunAdmission,
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
// 1. Build cache instances (in-memory or Redis depending on build tags)
agentCtxCache, userCtxCache := makeCaches(redisClient)
@@ -205,6 +207,17 @@ func wireExtras(
slog.Info("agent hooks dispatcher wired", "handlers", "command,http,prompt")
}
timelineRecorder := agent.NewRunTimelineRecorder(stores.RunTimeline)
// Reconcile runs left mid-execution by a previous gateway stop. A run whose
// process was killed never emits its terminal run.status, so it would show as
// perpetually "running" and never be recorded as failed. Mark such runs failed
// on startup, mirroring the cron scheduler's stale-'running' reset.
if stores.RunTimeline != nil {
if n, err := stores.RunTimeline.RecoverInterruptedRuns(context.Background()); err != nil {
slog.Warn("run timeline: failed to recover interrupted runs on startup", "error", err)
} else if n > 0 {
slog.Info("run timeline: marked interrupted runs as failed on startup", "count", n)
}
}
resolver := agent.NewManagedResolver(agent.ResolverDeps{
AgentStore: stores.Agents,
@@ -243,6 +256,7 @@ func wireExtras(
MCPStore: stores.MCP,
MCPPool: mcpPool,
MCPGrantChecker: mcpGrantChecker,
MCPOAuthTokenProvider: mcpOAuthProvider,
ConfigPermStore: stores.ConfigPermissions,
MediaStore: mediaStore,
ModelPricing: appCfg.Telemetry.ModelPricing,
@@ -437,25 +451,32 @@ func wireExtras(
// Link delegate trace to parent trace
delegateCtx := tracing.WithDelegateParentTraceID(ctx, tracing.TraceIDFromContext(ctx))
runReq := agent.RunRequest{
RunID: uuid.New().String(),
SessionKey: sessionKey,
Message: req.Task,
UserID: req.UserID,
Channel: "delegate",
RunKind: "delegate",
DelegationID: req.DelegationID,
ParentAgentID: req.FromAgentKey,
runReq := buildAgentLinkRunRequest(req, sessionKey)
var delegateTraceID uuid.UUID
runReq.OnTraceCreated = func(traceID uuid.UUID) {
delegateTraceID = traceID
if req.OnTraceCreated != nil {
req.OnTraceCreated(traceID)
}
}
result, err := loop.Run(delegateCtx, runReq)
if err != nil {
return tools.DelegateResult{}, err
result, runErr := loop.Run(delegateCtx, runReq)
if releaseErr := releaseDelegationSandbox(ctx, sandboxMgr, sessionKey); releaseErr != nil {
return tools.DelegateResult{TraceID: delegateTraceID}, releaseErr
}
cr := orchestration.CaptureFromRunResult(result, 0)
return tools.DelegateResult{Content: cr.Content, Media: cr.Media}, nil
if runErr != nil {
return tools.DelegateResult{TraceID: delegateTraceID}, runErr
}
return tools.DelegateResult{
Content: result.Content,
Media: agentMediaToBusFiles(result.Media),
TraceID: delegateTraceID,
}, nil
}
delegateTool := tools.NewDelegateTool(stores.AgentLinks, stores.Agents, domainBus, delegateRunFn)
delegateTool := tools.NewDelegateToolWithAdmission(stores.AgentLinks, stores.Agents, domainBus, delegateRunFn, childRunAdmission)
delegateTool.SetDataDir(appCfg.DataDir)
delegateTool.SetWorkspace(workspace)
delegateTool.SetMsgBus(msgBus)
delegateTool.SetTaskStore(stores.SubagentTasks)
delegateTool.SetHookDispatcher(hookDispatcher)
toolsReg.Register(delegateTool)
slog.Info("delegate tool wired")
@@ -535,7 +556,9 @@ func wireExtras(
agentRouter.InvalidateAll()
})
// MCP cache: invalidate all agent caches when MCP servers/grants change
// MCP cache: invalidate all agent caches + per-user pool connections when MCP servers/grants change.
// Per-user pool connections hold stale credentials/headers; evicting them forces a fresh
// AcquireUser on next request so new OAuth tokens and grant changes take effect immediately.
msgBus.Subscribe(bus.TopicCacheMCP, func(event bus.Event) {
if event.Name != protocol.EventCacheInvalidate {
return
@@ -545,6 +568,9 @@ func wireExtras(
return
}
agentRouter.InvalidateAll()
if mcpPool != nil {
mcpPool.EvictAllUsers()
}
})
// Cron cache: invalidate job cache on cron changes
@@ -621,6 +647,14 @@ func wireExtras(
if stores.Teams != nil && stores.Agents != nil {
teamMgr := tools.NewTeamToolManager(stores.Teams, stores.Agents, msgBus, workspace)
postTurn = teamMgr
// Async delegations run detached from the caller's turn, so they need their
// own post-turn dispatch. The delegate tool is registered above, before the
// team manager exists — wire it now that postTurn is available.
if delegateTool, ok := toolsReg.Get("delegate"); ok {
if dt, ok := delegateTool.(*tools.DelegateTool); ok {
dt.SetPostTurnProcessor(postTurn)
}
}
var teamPolicy tools.TeamActionPolicy = tools.FullTeamPolicy{}
if !edition.Current().TeamFullMode {
teamPolicy = tools.LiteTeamPolicy{}
+10 -3
View File
@@ -10,18 +10,21 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, 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) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, tracingStore store.TracingStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, agentLinkStore store.AgentLinkStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service, providerReg *providers.Registry, teamWorkEmbedder memory.EmbeddingProvider) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
router := server.Router()
// Phase 1: Core methods
chatMethods := methods.NewChatMethods(agents, sessStore, cfg, server.RateLimiter(), msgBus)
chatMethods.SetAudioManager(audioMgr) // Wire TTS auto-apply for WS responses
chatMethods.SetUsageCapService(usageCapSvc)
chatMethods.SetTeamWorkClassification(agentStore, teamStore, agentLinkStore, teamWorkEmbedder)
chatMethods.Register(router)
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
@@ -64,7 +67,11 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
pairingMethods.Register(router)
// Phase 2: Usage (queries SessionStore for real token data)
methods.NewUsageMethods(sessStore).Register(router)
methods.NewUsageMethods(sessStore, tracingStore).Register(router)
methods.NewLLMMethods(providerReg, cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel).Register(router)
// Wire the same provider registry into the CRUD MCP server (see
// internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()).
server.SetLLMProviders(providerReg, cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel)
// Phase 2: Exec approval (always registered — returns empty when manager is nil)
methods.NewExecApprovalMethods(execApprovalMgr, msgBus).Register(router)
@@ -77,7 +84,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
slog.Info("registered all RPC methods",
"phase1", []string{"chat", "agents", "sessions", "config"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "llm", "exec_approval", "send"},
)
return pairingMethods, heartbeatMethods, chatMethods, cfgPerms
+96 -24
View File
@@ -43,6 +43,17 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe
slog.Info("registered provider", "name", "openai")
}
if cfg.Providers.AtlasCloud.APIKey != "" {
base := cfg.Providers.AtlasCloud.APIBase
if base == "" {
base = store.AtlasCloudDefaultAPIBase
}
prov := providers.NewOpenAIProvider("atlascloud", cfg.Providers.AtlasCloud.APIKey, base, store.AtlasCloudDefaultModel)
prov.WithProviderType(store.ProviderAtlasCloud)
registry.Register(prov)
slog.Info("registered provider", "name", "atlascloud")
}
if cfg.Providers.APIRoute.APIKey != "" {
base := cfg.Providers.APIRoute.APIBase
if base == "" {
@@ -87,8 +98,11 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe
}
if cfg.Providers.MiniMax.APIKey != "" {
registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, "https://api.minimax.io/v1", "MiniMax-M2.5").
WithChatPath("/text/chatcompletion_v2"))
base := cfg.Providers.MiniMax.APIBase
if base == "" {
base = store.MiniMaxDefaultAPIBase
}
registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, base, store.MiniMaxDefaultModel))
slog.Info("registered provider", "name", "minimax")
}
@@ -112,43 +126,59 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus"))
registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus").
WithProviderType(store.ProviderBailian))
slog.Info("registered provider", "name", "bailian")
}
if cfg.Providers.Zai.APIKey != "" {
base := cfg.Providers.Zai.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
base = store.ZaiDefaultAPIBase
}
registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, "glm-5"))
registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, store.ZaiDefaultModel))
slog.Info("registered provider", "name", "zai")
}
if cfg.Providers.ZaiCoding.APIKey != "" {
base := cfg.Providers.ZaiCoding.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
base = store.ZaiCodingDefaultAPIBase
}
registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, "glm-5"))
registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, store.ZaiDefaultModel))
slog.Info("registered provider", "name", "zai-coding")
}
// Local / self-hosted Ollama — gated on Host, no API key required.
// Ollama's OpenAI-compat endpoint accepts any non-empty Bearer value.
// Uses the native Ollama Go client for proper options.num_ctx support.
if cfg.Providers.Ollama.Host != "" {
host := cfg.Providers.Ollama.Host
registry.Register(providers.NewOpenAIProvider("ollama", "ollama", host+"/v1", "llama3.3"))
ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second)
numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(host), "llama3.3", "")
cancel()
var numCtxPtr *int
if numCtx != providers.OllamaDefaultNumCtx {
numCtxPtr = &numCtx
}
registry.Register(providers.NewOllamaProvider("ollama", host, "llama3.3", numCtxPtr, nil))
slog.Info("registered provider", "name", "ollama")
}
// Ollama Cloud — API key required (generate at ollama.com/settings/keys).
// Uses the native Ollama Go client; the cloud endpoint is Ollama-native, not OpenAI-compat.
if cfg.Providers.OllamaCloud.APIKey != "" {
base := cfg.Providers.OllamaCloud.APIBase
if base == "" {
base = "https://ollama.com/v1"
base = "https://ollama.com"
}
registry.Register(providers.NewOpenAIProvider("ollama-cloud", cfg.Providers.OllamaCloud.APIKey, base, "llama3.3"))
ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second)
numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(base), "llama3.3", "")
cancel()
var numCtxPtr *int
if numCtx != providers.OllamaDefaultNumCtx {
numCtxPtr = &numCtx
}
registry.Register(providers.NewOllamaProvider("ollama-cloud", base, "llama3.3", numCtxPtr, nil))
slog.Info("registered provider", "name", "ollama-cloud")
}
@@ -306,9 +336,12 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
if p.ProviderType == store.ProviderOllama {
host := p.APIBase
if host == "" {
host = "http://localhost:11434/v1"
host = "http://localhost:11434"
}
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host), "llama3.3"))
numCtx := resolveOllamaNumCtx(&p)
prov := providers.NewOllamaProvider(p.Name, config.DockerLocalhost(host), "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
registry.RegisterForTenant(p.TenantID, prov)
slog.Info("registered provider from DB", "name", p.Name)
continue
}
@@ -367,25 +400,29 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus").
WithProviderType(p.ProviderType))
case store.ProviderZai:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
base = store.ZaiDefaultAPIBase
}
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel))
case store.ProviderZaiCoding:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
base = store.ZaiCodingDefaultAPIBase
}
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel))
case store.ProviderOllamaCloud:
base := p.APIBase
if base == "" {
base = "https://ollama.com/v1"
base = "https://ollama.com"
}
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
numCtx := resolveOllamaNumCtx(&p)
prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil).
WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
registry.RegisterForTenant(p.TenantID, prov)
case store.ProviderNovita:
base := p.APIBase
if base == "" {
@@ -421,6 +458,10 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
"User-Agent": store.KimiCodingRequiredUserAgent,
})
registry.RegisterForTenant(p.TenantID, prov)
case store.ProviderAIMLAPI:
prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, p.APIBase)
prov.WithProviderType(p.ProviderType)
registry.RegisterForTenant(p.TenantID, prov)
case store.ProviderAPIRoute:
base := p.APIBase
if base == "" {
@@ -430,11 +471,10 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
prov.WithProviderType(p.ProviderType)
registry.RegisterForTenant(p.TenantID, prov)
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
base, model := openAIProviderDefaults(p.ProviderType, p.APIBase)
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model)
prov.WithProviderType(p.ProviderType)
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
prov.WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings))
if p.ProviderType == store.ProviderOpenRouter {
prov.WithSiteInfo("https://goclaw.sh", "GoClaw")
}
@@ -444,6 +484,38 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}
}
func openAIProviderDefaults(providerType, apiBase string) (string, string) {
switch providerType {
case store.ProviderMiniMax:
if apiBase == "" {
apiBase = store.MiniMaxDefaultAPIBase
}
return apiBase, store.MiniMaxDefaultModel
case store.ProviderAtlasCloud:
if apiBase == "" {
apiBase = store.AtlasCloudDefaultAPIBase
}
return apiBase, store.AtlasCloudDefaultModel
default:
return apiBase, ""
}
}
// resolveOllamaNumCtx returns the operator-configured num_ctx for an Ollama
// provider, or nil to let the provider resolve it per model at request time.
//
// Only the explicit settings JSONB override is honoured here. Probing /api/show
// at startup cannot work: the model an agent will use is not known until it
// sends a request, so the probe had to guess a model name, and a wrong guess
// resolved to nothing. OllamaProvider.resolveNumCtx does the lookup against the
// real model instead, and caches it.
func resolveOllamaNumCtx(p *store.LLMProviderData) *int {
if s := store.ParseOllamaSettings(p.Settings); s != nil {
return s.NumCtx
}
return nil
}
func registerClaudeCLIFromConfig(registry *providers.Registry, cfg *config.Config) {
if cfg == nil || cfg.Providers.ClaudeCLI.CLIPath == "" {
return
+176
View File
@@ -0,0 +1,176 @@
package cmd
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestRegisterProvidersUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) {
cfg := &config.Config{}
cfg.Providers.AtlasCloud.APIKey = "atlas-token"
cfg.Providers.MiniMax.APIKey = "minimax-token"
cfg.Providers.Zai.APIKey = "zai-token"
cfg.Providers.ZaiCoding.APIKey = "zai-coding-token"
registry := providers.NewRegistry(nil)
registerProviders(registry, cfg, providers.NewInMemoryRegistry())
assertProviderDefault(t, registry, providers.MasterTenantID, "atlascloud", "qwen/qwen3.5-flash", "https://api.atlascloud.ai/v1")
assertProviderDefault(t, registry, providers.MasterTenantID, "minimax", "MiniMax-M3", "https://api.minimax.io/v1")
assertProviderDefault(t, registry, providers.MasterTenantID, "zai", "glm-5.2", "https://api.z.ai/api/paas/v4")
assertProviderDefault(t, registry, providers.MasterTenantID, "zai-coding", "glm-5.2", "https://api.z.ai/api/coding/paas/v4")
}
func TestRegisterProvidersMiniMaxUsesOpenAIChatCompletionsPath(t *testing.T) {
var capturedPath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
_ = json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{
"message": map[string]string{"content": "ok"},
"finish_reason": "stop",
},
},
})
}))
t.Cleanup(upstream.Close)
cfg := &config.Config{}
cfg.Providers.MiniMax.APIKey = "minimax-token"
cfg.Providers.MiniMax.APIBase = upstream.URL
registry := providers.NewRegistry(nil)
registerProviders(registry, cfg, providers.NewInMemoryRegistry())
runtimeProvider, err := registry.GetForTenant(providers.MasterTenantID, "minimax")
if err != nil {
t.Fatalf("GetForTenant() error = %v", err)
}
_, err = runtimeProvider.Chat(context.Background(), providers.ChatRequest{
Messages: []providers.Message{{Role: "user", Content: "hi"}},
})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if capturedPath != "/chat/completions" {
t.Fatalf("captured path = %q, want /chat/completions", capturedPath)
}
}
func TestRegisterProvidersFromDBUsesCurrentMiniMaxAndZaiDefaults(t *testing.T) {
tenantID := uuid.New()
providerStore := gatewayProvidersStoreStub{
providers: []store.LLMProviderData{
{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "db-aimlapi",
ProviderType: store.ProviderAIMLAPI,
APIKey: "aimlapi-token",
Enabled: true,
},
{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "db-atlascloud",
ProviderType: store.ProviderAtlasCloud,
APIKey: "atlas-token",
Enabled: true,
},
{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "db-minimax",
ProviderType: store.ProviderMiniMax,
APIKey: "minimax-token",
Enabled: true,
},
{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "db-zai",
ProviderType: store.ProviderZai,
APIKey: "zai-token",
Enabled: true,
},
{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "db-zai-coding",
ProviderType: store.ProviderZaiCoding,
APIKey: "zai-coding-token",
Enabled: true,
},
},
}
registry := providers.NewRegistry(nil)
registerProvidersFromDB(registry, providerStore, nil, "", "", nil, &config.Config{}, providers.NewInMemoryRegistry())
assertProviderDefault(t, registry, tenantID, "db-aimlapi", providers.AIMLAPIDefaultModel, providers.AIMLAPIDefaultAPIBase)
assertProviderDefault(t, registry, tenantID, "db-atlascloud", "qwen/qwen3.5-flash", "https://api.atlascloud.ai/v1")
assertProviderDefault(t, registry, tenantID, "db-minimax", "MiniMax-M3", "https://api.minimax.io/v1")
assertProviderDefault(t, registry, tenantID, "db-zai", "glm-5.2", "https://api.z.ai/api/paas/v4")
assertProviderDefault(t, registry, tenantID, "db-zai-coding", "glm-5.2", "https://api.z.ai/api/coding/paas/v4")
}
func assertProviderDefault(t *testing.T, registry *providers.Registry, tenantID uuid.UUID, name, wantModel, wantBase string) {
t.Helper()
runtimeProvider, err := registry.GetForTenant(tenantID, name)
if err != nil {
t.Fatalf("GetForTenant(%q) error = %v", name, err)
}
if got := runtimeProvider.DefaultModel(); got != wantModel {
t.Fatalf("%s DefaultModel() = %q, want %q", name, got, wantModel)
}
openai, ok := runtimeProvider.(*providers.OpenAIProvider)
if !ok {
t.Fatalf("%s runtime provider = %T, want *providers.OpenAIProvider", name, runtimeProvider)
}
if got := openai.APIBase(); got != wantBase {
t.Fatalf("%s APIBase() = %q, want %q", name, got, wantBase)
}
}
type gatewayProvidersStoreStub struct {
providers []store.LLMProviderData
}
func (s gatewayProvidersStoreStub) CreateProvider(context.Context, *store.LLMProviderData) error {
return errors.New("not implemented")
}
func (s gatewayProvidersStoreStub) GetProvider(context.Context, uuid.UUID) (*store.LLMProviderData, error) {
return nil, errors.New("not implemented")
}
func (s gatewayProvidersStoreStub) GetProviderByName(context.Context, string) (*store.LLMProviderData, error) {
return nil, errors.New("not implemented")
}
func (s gatewayProvidersStoreStub) ListProviders(context.Context) ([]store.LLMProviderData, error) {
return nil, errors.New("not implemented")
}
func (s gatewayProvidersStoreStub) ListAllProviders(context.Context) ([]store.LLMProviderData, error) {
return s.providers, nil
}
func (s gatewayProvidersStoreStub) UpdateProvider(context.Context, uuid.UUID, map[string]any) error {
return errors.New("not implemented")
}
func (s gatewayProvidersStoreStub) DeleteProvider(context.Context, uuid.UUID) error {
return errors.New("not implemented")
}
+175 -23
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -16,6 +17,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/edition"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
@@ -94,9 +96,12 @@ func setupToolRegistry(
// Browser automation tool
if cfg.Tools.Browser.Enabled {
var opts []browser.Option
if cfg.Tools.Browser.Backend != "" {
opts = append(opts, browser.WithBackend(browser.Backend(cfg.Tools.Browser.Backend)))
}
if cfg.Tools.Browser.RemoteURL != "" {
opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL))
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL)
slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL, "backend", cfg.Tools.Browser.Backend)
} else {
opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless))
slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless)
@@ -161,14 +166,11 @@ func setupToolRegistry(
slog.Info("credential scrubbing disabled")
}
// MCP servers (config-based: shared across all agents)
if len(cfg.Tools.McpServers) > 0 {
mcpMgr = mcpbridge.NewManager(toolsReg, mcpbridge.WithConfigs(cfg.Tools.McpServers))
if err := mcpMgr.Start(context.Background()); err != nil {
slog.Warn("mcp.startup_errors", "error", err)
}
slog.Info("MCP servers initialized", "configured", len(cfg.Tools.McpServers), "tools", len(mcpMgr.ToolNames()))
}
// MCP servers are loaded from the database in gateway.go after the store is
// initialised. The manager is created here so that the return value is always
// non-nil and downstream wiring (pool, grant-checker, etc.) can be applied
// unconditionally in gateway.go.
mcpMgr = mcpbridge.NewManager(toolsReg)
// Exec approval system — always active (deny patterns + safe bins + configurable ask mode)
{
@@ -304,7 +306,7 @@ func wireTracingAndCron(
) (*tracing.Collector, *tracing.SnapshotWorker) {
var traceCollector *tracing.Collector
if stores.Tracing != nil {
traceCollector = tracing.NewCollector(stores.Tracing)
traceCollector = tracing.NewCollector(stores.Tracing, stores.UsageEvents)
traceCollector.OnFlush = func(traceIDs []uuid.UUID) {
ids := make([]string, len(traceIDs))
for i, id := range traceIDs {
@@ -372,9 +374,11 @@ func wireTracingAndCron(
func setupMemoryEmbeddings(
pgStores *store.Stores,
providerRegistry *providers.Registry,
) {
) memory.EmbeddingProvider {
var resolved memory.EmbeddingProvider
if pgStores.Memory != nil {
if embProvider := resolveEmbeddingProvider(pgStores.Providers, providerRegistry, pgStores.SystemConfigs); embProvider != nil {
resolved = embProvider
pgStores.Memory.SetEmbeddingProvider(embProvider)
slog.Info("memory embeddings enabled", "provider", embProvider.Name(), "model", embProvider.Model())
@@ -419,20 +423,66 @@ func setupMemoryEmbeddings(
}
// Wire embedding provider into vault store for semantic document search.
var vaultStore *pg.PGVaultStore
if pgStores.Vault != nil {
pgStores.Vault.SetEmbeddingProvider(embProvider)
slog.Info("vault embeddings enabled", "provider", embProvider.Name())
vaultStore, _ = pgStores.Vault.(*pg.PGVaultStore)
}
// V3: Wire embedding provider into episodic store for semantic search.
var episodicStore *pg.PGEpisodicStore
if pgStores.Episodic != nil {
pgStores.Episodic.SetEmbeddingProvider(embProvider)
slog.Info("episodic embeddings enabled", "provider", embProvider.Name())
episodicStore, _ = pgStores.Episodic.(*pg.PGEpisodicStore)
}
// Agent create/update embedding hooks require the provider to be wired.
var agentStore *pg.PGAgentStore
if pgAgentStore, ok := pgStores.Agents.(*pg.PGAgentStore); ok {
agentStore = pgAgentStore
agentStore.SetEmbeddingProvider(embProvider)
slog.Info("agent embeddings enabled", "provider", embProvider.Name())
}
// Recover the remaining semantic indexes sequentially to avoid a burst
// of concurrent batch requests during gateway startup. Each surface gets
// its own deadline so a large agent backlog cannot starve later stores.
go func() {
if agentStore != nil {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
if count, err := agentStore.BackfillAgentEmbeddings(bgCtx); err != nil {
slog.Warn("agent embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("agent embeddings recovery complete", "agents_updated", count)
}
cancel()
}
if episodicStore != nil {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
if count, err := episodicStore.BackfillEpisodicEmbeddings(bgCtx); err != nil {
slog.Warn("episodic embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("episodic embeddings backfill complete", "summaries_updated", count)
}
cancel()
}
if vaultStore != nil {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
if count, err := vaultStore.BackfillVaultEmbeddings(bgCtx); err != nil {
slog.Warn("vault embeddings backfill failed", "error", err)
} else if count > 0 {
slog.Info("vault embeddings backfill complete", "documents_updated", count)
}
cancel()
}
}()
} else {
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.
@@ -529,7 +579,7 @@ func setupSkillsSystem(
skillsLoader := skills.NewLoader(workspace, globalSkillsDir, builtinSkillsDir)
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
toolsReg.Register(skillSearchTool)
toolsReg.Register(tools.NewUseSkillTool())
toolsReg.Register(tools.NewUseSkillTool(skillsLoader))
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(context.Background())))
// Wire skills-store directory into filesystem loader so agents
@@ -537,8 +587,11 @@ func setupSkillsSystem(
if pgStores.Skills != nil {
storeDirs := pgStores.Skills.Dirs()
if len(storeDirs) > 0 {
skillsLoader.SetManagedDir(storeDirs[0])
slog.Info("skills-store directory wired into loader", "dir", storeDirs[0])
// Pass the root data dir, not storeDirs[0] (which is the master
// tenant's pre-resolved skills-store path) — the loader resolves
// each tenant's own skills-store directory per request from this root.
skillsLoader.SetManagedDir(dataDir)
slog.Info("skills-store directory wired into loader", "dataDir", dataDir)
// Seed system/bundled skills into DB
bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
@@ -557,16 +610,33 @@ func setupSkillsSystem(
seeded, skipped, seededSkills, err := seeder.Seed(context.Background())
if err != nil {
slog.Warn("system skills seed failed", "error", err)
} else {
if seeded > 0 {
slog.Info("system skills seeded", "seeded", seeded, "skipped", skipped)
}
// Check dependencies asynchronously — does not block startup.
// Emits WS events per-skill so UI updates in realtime.
if len(seededSkills) > 0 {
seeder.CheckDepsAsync(seededSkills, msgBus)
}
}
if seeded > 0 {
slog.Info("system skills seeded", "seeded", seeded, "skipped", skipped)
}
// Check dependencies for successful partial results even when another
// bundled skill needs manual recovery.
if len(seededSkills) > 0 {
seeder.CheckDepsAsync(seededSkills, msgBus)
}
}
}
// Register on-disk managed skills (skills-store) that are missing from
// the database. A skill placed directly into the tenant's skills-store
// without a skills row is invisible to agents (skill visibility is
// DB-driven), which manifests as goclaw not detecting a skill the user
// typed triggers for. Reconcile closes that gap idempotently.
if reconcileStore, ok := pgStores.Skills.(skills.ManagedSkillStore); ok {
reconciler := skills.NewReconciler(reconcileStore)
if n, err := reconciler.Reconcile(
context.Background(),
store.MasterTenantID,
storeDirs[0],
); err != nil {
slog.Warn("skills-store reconcile failed", "error", err)
} else if n > 0 {
slog.Info("skills-store reconcile complete", "registered", n)
}
}
}
@@ -612,3 +682,85 @@ func setupSkillsSystem(
return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir, builtinSkillsDir
}
// initMCPFromDB loads all enabled MCP servers from the database and connects them
// into the shared manager. This replaces the former config-file-based initialisation.
// Non-fatal: individual server connection failures are logged as warnings.
func initMCPFromDB(ctx context.Context, mgr *mcpbridge.Manager, mcpStore store.MCPServerStore) error {
slog.Debug("initMCPFromDB starting")
slog.Debug("querying mcp_servers from database")
servers, err := mcpStore.ListServers(ctx)
if err != nil {
slog.Error("initMCPFromDB: failed to query mcp_servers", "error", err)
return fmt.Errorf("list mcp servers from db: %w", err)
}
slog.Debug("found mcp_servers from database", "count", len(servers))
cfgs := make(map[string]*config.MCPServerConfig, len(servers))
for i := range servers {
srv := &servers[i]
slog.Debug("initMCPFromDB: processing server", "name", srv.Name, "transport", srv.Transport, "enabled", srv.Enabled)
if !srv.Enabled {
slog.Debug("initMCPFromDB: skipping disabled server", "name", srv.Name)
continue
}
var args []string
if len(srv.Args) > 0 {
if jsonErr := json.Unmarshal(srv.Args, &args); jsonErr != nil {
slog.Warn("mcp.db.invalid_args", "server", srv.Name, "error", jsonErr)
}
}
var headers map[string]string
if len(srv.Headers) > 0 {
if jsonErr := json.Unmarshal(srv.Headers, &headers); jsonErr != nil {
slog.Warn("mcp.db.invalid_headers", "server", srv.Name, "error", jsonErr)
}
}
var env map[string]string
if len(srv.Env) > 0 {
if jsonErr := json.Unmarshal(srv.Env, &env); jsonErr != nil {
slog.Warn("mcp.db.invalid_env", "server", srv.Name, "error", jsonErr)
}
}
// Inject decrypted APIKey as Authorization header when not already set.
if srv.APIKey != "" && headers["Authorization"] == "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + srv.APIKey
}
enabled := true
cfgs[srv.Name] = &config.MCPServerConfig{
Transport: srv.Transport,
Command: srv.Command,
Args: args,
Env: env,
URL: srv.URL,
Headers: headers,
Enabled: &enabled,
ToolPrefix: srv.ToolPrefix,
TimeoutSec: srv.TimeoutSec,
}
}
if len(cfgs) == 0 {
slog.Debug("mcp.db: no enabled servers found")
return nil
}
slog.Debug("initMCPFromDB: building config map", "servers", len(cfgs))
slog.Debug("initMCPFromDB: calling mgr.SetConfigs()")
mgr.SetConfigs(cfgs)
slog.Debug("initMCPFromDB: calling mgr.Start()")
if startErr := mgr.Start(ctx); startErr != nil {
slog.Warn("mcp.db.startup_errors", "error", startErr)
}
toolCount := len(mgr.ToolNames())
slog.Debug("initMCPFromDB: MCP init complete", "tools_registered", toolCount)
return nil
}
+6 -3
View File
@@ -14,11 +14,14 @@ func buildMergedSubagentAnnounce(entries []subagentAnnounceEntry, roster tools.S
if len(entries) == 1 {
e := entries[0]
statusLabel := "completed successfully"
if e.Status == "failed" {
var statusLabel string
switch e.Status {
case "failed":
statusLabel = "failed"
} else if e.Status == "cancelled" {
case "cancelled":
statusLabel = "was cancelled"
default:
statusLabel = "completed successfully"
}
fmt.Fprintf(&sb, "[System Message] A subagent task %q just %s.\n\nResult:\n%s\n\nStats: runtime %s, iterations %d, tokens %d in / %d out\n",
e.Label, statusLabel, e.Content,
+47
View File
@@ -0,0 +1,47 @@
package cmd
import (
"testing"
"github.com/google/uuid"
)
func TestSubagentAnnounceRoutingKeyScopesRoutingAndAuthority(t *testing.T) {
base := subagentAnnounceRouting{
TenantID: uuid.New(),
RootAgentID: uuid.New(),
ParentAgent: "root",
SessionKey: "session",
OrigChannel: "telegram",
OrigChatID: "chat",
OrigPeerKind: "group",
OrigLocalKey: "topic",
UserID: "user",
SenderID: "sender",
Role: "operator",
}
want := subagentAnnounceRoutingKey(base)
cases := map[string]func(*subagentAnnounceRouting){
"tenant": func(v *subagentAnnounceRouting) { v.TenantID = uuid.New() },
"root id": func(v *subagentAnnounceRouting) { v.RootAgentID = uuid.New() },
"parent agent": func(v *subagentAnnounceRouting) { v.ParentAgent += "-other" },
"session": func(v *subagentAnnounceRouting) { v.SessionKey += "-other" },
"channel": func(v *subagentAnnounceRouting) { v.OrigChannel += "-other" },
"chat": func(v *subagentAnnounceRouting) { v.OrigChatID += "-other" },
"peer kind": func(v *subagentAnnounceRouting) { v.OrigPeerKind += "-other" },
"local key": func(v *subagentAnnounceRouting) { v.OrigLocalKey += "-other" },
"user": func(v *subagentAnnounceRouting) { v.UserID += "-other" },
"sender": func(v *subagentAnnounceRouting) { v.SenderID += "-other" },
"role": func(v *subagentAnnounceRouting) { v.Role += "-other" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
got := base
mutate(&got)
if key := subagentAnnounceRoutingKey(got); key == want {
t.Fatalf("routing key did not change when %s changed", name)
}
})
}
}
+43 -10
View File
@@ -11,6 +11,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
orch "github.com/nextlevelbuilder/goclaw/internal/orchestration"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
@@ -26,7 +27,9 @@ func makeDelegateAnnounceCallback(
msgBus *bus.MessageBus,
) func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
return func(sessionKey string, items []tools.AnnounceQueueItem, meta tools.AnnounceMetadata) {
roster := subagentMgr.RosterForParent(meta.ParentAgent)
roster := subagentMgr.RosterForParent(tools.TaskScope{
TenantID: meta.OriginTenantID, RootAgentID: meta.RootAgentID, RootAgentKey: meta.ParentAgent,
})
content := tools.FormatBatchedAnnounce(items, roster)
senderID := fmt.Sprintf("subagent:batch-%d", len(items))
label := items[0].Label
@@ -34,12 +37,13 @@ func makeDelegateAnnounceCallback(
label = fmt.Sprintf("%d tasks", len(items))
}
batchMeta := map[string]string{
tools.MetaOriginChannel: meta.OriginChannel,
tools.MetaOriginPeerKind: meta.OriginPeerKind,
tools.MetaParentAgent: meta.ParentAgent,
tools.MetaSubagentLabel: label,
tools.MetaOriginTraceID: meta.OriginTraceID,
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
tools.MetaOriginChannel: meta.OriginChannel,
tools.MetaOriginPeerKind: meta.OriginPeerKind,
tools.MetaParentAgent: meta.ParentAgent,
tools.MetaSubagentRootAgentID: meta.RootAgentID.String(),
tools.MetaSubagentLabel: label,
tools.MetaOriginTraceID: meta.OriginTraceID,
tools.MetaOriginRootSpanID: meta.OriginRootSpanID,
}
if meta.OriginLocalKey != "" {
batchMeta[tools.MetaOriginLocalKey] = meta.OriginLocalKey
@@ -68,7 +72,7 @@ func makeDelegateAnnounceCallback(
"tasks": len(items),
})
msgBus.PublishInbound(bus.InboundMessage{
delivered := tools.PublishAsyncCompletion(context.Background(), msgBus, bus.InboundMessage{
Channel: "system",
SenderID: senderID,
ChatID: meta.OriginChatID,
@@ -78,6 +82,30 @@ func makeDelegateAnnounceCallback(
Metadata: batchMeta,
Media: batchMedia,
})
for _, item := range items {
if !item.DurablyPersisted {
slog.Error("subagent.batch_announce_without_durable_terminal",
"task_id", item.SubagentID,
"completion_id", item.CompletionID,
"root_agent_id", meta.RootAgentID,
"delivered", delivered,
)
continue
}
subagentMgr.UpdateAnnouncementStatus(
store.WithTenantID(context.Background(), meta.OriginTenantID),
meta.RootAgentID,
item.CompletionID,
delivered,
)
}
if !delivered {
slog.Warn("subagent.batch_announce_deferred_to_ledger",
"root_agent_id", meta.RootAgentID,
"batch_size", len(items),
"reason", "inbound_bus_full",
)
}
}
}
@@ -95,7 +123,7 @@ type subagentAnnounceEntry struct {
// subagentAnnounceRouting holds shared routing info captured by the first enqueue.
type subagentAnnounceRouting struct {
QueueKey string // tenant-scoped key for sync.Map (tenantID:sessionKey)
QueueKey string // tenant/root/session/topic/user/authority-scoped key
SessionKey string // original session key (no tenant prefix) for RunRequest
TenantID uuid.UUID // preserved for tenant-scoped scheduling
OrigChannel string
@@ -107,6 +135,7 @@ type subagentAnnounceRouting struct {
SenderID string // real acting sender (preserves permission attribution through re-ingress, #915)
Role string // caller's RBAC role; bypasses per-user grants for admin/operator/owner (#915)
ParentAgent string
RootAgentID uuid.UUID
ParentTraceID uuid.UUID
ParentRootSpanID uuid.UUID
OutMeta map[string]string
@@ -129,6 +158,7 @@ func processSubagentAnnounceLoop(
sched *scheduler.Scheduler,
msgBus *bus.MessageBus,
cfg *config.Config,
channelMgr *channels.Manager,
) {
// Ensure tenant scope is always set for the scheduler.
if r.TenantID != uuid.Nil {
@@ -154,7 +184,9 @@ func processSubagentAnnounceLoop(
}
// Refresh roster each iteration for up-to-date task statuses.
roster = subagentMgr.RosterForParent(r.ParentAgent)
roster = subagentMgr.RosterForParent(tools.TaskScope{
TenantID: r.TenantID, RootAgentID: r.RootAgentID, RootAgentKey: r.ParentAgent,
})
content := buildMergedSubagentAnnounce(entries, roster)
// Collect media from all entries.
@@ -176,6 +208,7 @@ func processSubagentAnnounceLoop(
Channel: r.OrigChannel,
ChannelType: r.OrigChannelType,
ChatID: r.OrigChatID,
ChatTitle: resolveGroupDisplayTitle(ctx, channelMgr, r.OrigChannel, r.OrigChatID, r.OrigPeerKind, ""),
PeerKind: r.OrigPeerKind,
LocalKey: r.OrigLocalKey,
UserID: r.UserID,
@@ -0,0 +1,89 @@
package cmd
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
type recordingGatewayTaskStore struct {
metadata chan map[string]any
}
func (*recordingGatewayTaskStore) Create(context.Context, *store.SubagentTaskData) error {
return nil
}
func (*recordingGatewayTaskStore) Get(context.Context, uuid.UUID, uuid.UUID) (*store.SubagentTaskData, error) {
return nil, nil
}
func (*recordingGatewayTaskStore) UpdateStatus(context.Context, uuid.UUID, uuid.UUID, string, *string, int, int64, int64) error {
return nil
}
func (*recordingGatewayTaskStore) ListByParent(context.Context, uuid.UUID, string) ([]store.SubagentTaskData, error) {
return nil, nil
}
func (*recordingGatewayTaskStore) ListBySession(context.Context, uuid.UUID, string) ([]store.SubagentTaskData, error) {
return nil, nil
}
func (*recordingGatewayTaskStore) Archive(context.Context, uuid.UUID, time.Duration, int) (int64, error) {
return 0, nil
}
func (s *recordingGatewayTaskStore) UpdateMetadata(_ context.Context, _ uuid.UUID, _ uuid.UUID, metadata map[string]any) error {
s.metadata <- metadata
return nil
}
func TestSubagentBatchAnnouncementDoesNotBlockOnFullInboundBus(t *testing.T) {
messageBus := bus.New()
for range 1000 {
messageBus.PublishInbound(bus.InboundMessage{Content: "fill"})
}
manager := tools.NewSubagentManager(nil, nil, "", messageBus, nil, tools.SubagentConfig{})
taskStore := &recordingGatewayTaskStore{metadata: make(chan map[string]any, 1)}
manager.SetTaskStore(taskStore)
callback := makeDelegateAnnounceCallback(manager, messageBus)
completionID := uuid.New()
done := make(chan struct{})
go func() {
callback(
"session-1",
[]tools.AnnounceQueueItem{{
SubagentID: "task-1",
CompletionID: completionID,
DurablyPersisted: true,
Label: "probe",
Status: tools.TaskStatusCompleted,
Result: "done",
}},
tools.AnnounceMetadata{
OriginChatID: "chat-1",
OriginSessionKey: "session-1",
OriginTenantID: uuid.New(),
RootAgentID: uuid.New(),
ParentAgent: "root",
},
)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("batched subagent announcement blocked on a full inbound bus")
}
select {
case metadata := <-taskStore.metadata:
if metadata["announcement_status"] != "undelivered" {
t.Fatalf("announcement metadata = %#v, want undelivered", metadata)
}
case <-time.After(time.Second):
t.Fatal("batched missed announcement was not recorded after bus saturation")
}
}
+127
View File
@@ -0,0 +1,127 @@
package cmd
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// A subagent's file and exec tools are constructed fresh, so they start with none of the
// hardening the gateway applies to the parent's instances at startup. Before this was
// wired, spawning a subagent widened reach: the parent could not read config.json or
// exec against the data dir, the subagent could. These tests pin the inheritance.
func TestSubagentToolsInheritParentPolicy(t *testing.T) {
workspace := t.TempDir()
dataDir := t.TempDir()
// The denied files must exist. Without them a read or a `cat` fails because the file
// is missing, the assertion sees IsError and passes — for the wrong reason. Verified
// by disabling the fix: only the write_file assertion went red until these existed.
if err := os.WriteFile(filepath.Join(workspace, "config.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dataDir, "config.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
parent := tools.NewRegistry()
parentRead := tools.NewReadFileTool(workspace, true)
parentRead.DenyPaths("config.json", "memory.db")
parentWrite := tools.NewWriteFileTool(workspace, true)
parentWrite.DenyPaths("config.json", "delegate/")
parentList := tools.NewListFilesTool(workspace, true)
parentList.DenyPaths("config.json")
parentExec := tools.NewExecTool(workspace, true)
parentExec.DenyPaths(dataDir)
parent.Register(parentRead)
parent.Register(parentWrite)
parent.Register(parentList)
parent.Register(parentExec)
reg, execTool := buildSubagentToolsRegistry(parent, workspace, true, nil, nil)
if reg == nil || execTool == nil {
t.Fatal("buildSubagentToolsRegistry returned nil")
}
ctx := context.Background()
// exec: a command referencing the parent's denied data dir must be refused.
res := execTool.Execute(ctx, map[string]any{"command": "cat " + dataDir + "/config.json"})
if res == nil {
t.Fatal("exec returned nil result")
}
if !res.IsError {
t.Errorf("subagent exec reached the parent's denied data dir: %+v", res)
}
// read_file: the parent's denied prefixes must apply.
rf, ok := reg.Get("read_file")
if !ok {
t.Fatal("read_file missing from subagent registry")
}
res = rf.Execute(ctx, map[string]any{"path": "config.json"})
if res == nil || !res.IsError {
t.Errorf("subagent read_file reached config.json: %+v", res)
}
// write_file: same.
wf, ok := reg.Get("write_file")
if !ok {
t.Fatal("write_file missing from subagent registry")
}
res = wf.Execute(ctx, map[string]any{"path": "config.json", "content": "x"})
if res == nil || !res.IsError {
t.Errorf("subagent write_file reached config.json: %+v", res)
}
}
// Inheriting denials without the parent's exemptions would leave the subagent unable to
// read the skills it is told to use: the skills store sits under the denied data dir.
func TestSubagentExecInheritsParentPathExemptions(t *testing.T) {
workspace := t.TempDir()
dataDir := t.TempDir()
skillsStore := dataDir + "/skills-store/"
if err := os.MkdirAll(filepath.Join(skillsStore, "demo"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillsStore, "demo", "SKILL.md"), []byte("# demo\n"), 0o644); err != nil {
t.Fatal(err)
}
parent := tools.NewRegistry()
parentExec := tools.NewExecTool(workspace, true)
parentExec.DenyPaths(dataDir)
parentExec.AllowPathExemptions(skillsStore)
parent.Register(parentExec)
_, execTool := buildSubagentToolsRegistry(parent, workspace, true, nil, nil)
res := execTool.Execute(context.Background(), map[string]any{"command": "cat " + skillsStore + "demo/SKILL.md"})
if res == nil {
t.Fatal("exec returned nil result")
}
if res.IsError {
t.Errorf("exemption did not carry over; reading the skills store was refused: %+v", res)
}
if !strings.Contains(res.ForLLM, "# demo") {
t.Errorf("expected the skill file contents, got %q", res.ForLLM)
}
}
// A parent registry without the tools, or with nothing configured, must not panic.
func TestSubagentToolsInheritTolerantOfMissingParentTools(t *testing.T) {
workspace := t.TempDir()
reg, execTool := buildSubagentToolsRegistry(tools.NewRegistry(), workspace, true, nil, nil)
if reg == nil || execTool == nil {
t.Fatal("expected a usable registry from an empty parent")
}
if _, ok := reg.Get("read_file"); !ok {
t.Error("read_file should still be registered")
}
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type scriptedSubagentTaskRecovery struct {
mu sync.Mutex
failures int
recovered int64
calls int
}
func (s *scriptedSubagentTaskRecovery) RecoverInterrupted(ctx context.Context) (int64, error) {
if !store.IsMasterScope(ctx) || store.TenantIDFromContext(ctx) != store.MasterTenantID {
return 0, errors.New("recovery did not receive explicit master scope")
}
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
if s.calls <= s.failures {
return 0, errors.New("database temporarily unavailable")
}
return s.recovered, nil
}
func (s *scriptedSubagentTaskRecovery) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func TestRecoverInterruptedSubagentTasksRetriesBeforeTraffic(t *testing.T) {
recovery := &scriptedSubagentTaskRecovery{failures: 2, recovered: 3}
stores := &store.Stores{SubagentTaskRecovery: recovery}
recovered, err := recoverInterruptedSubagentTasks(
context.Background(), stores, time.Millisecond,
)
if err != nil {
t.Fatalf("recoverInterruptedSubagentTasks: %v", err)
}
if recovered != 3 || recovery.callCount() != 3 {
t.Fatalf("recovery = (%d rows, %d calls), want (3, 3)", recovered, recovery.callCount())
}
}
func TestRecoverInterruptedSubagentTasksStopsOnShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
recovery := &scriptedSubagentTaskRecovery{failures: 1}
stores := &store.Stores{SubagentTaskRecovery: recovery}
_, err := recoverInterruptedSubagentTasks(ctx, stores, time.Hour)
if !errors.Is(err, context.Canceled) {
t.Fatalf("recovery error = %v, want context.Canceled", err)
}
if recovery.callCount() != 1 {
t.Fatalf("recovery calls = %d, want 1", recovery.callCount())
}
}
+4
View File
@@ -108,6 +108,7 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
setIntAllowZero("gateway.inbound_debounce_ms", cfg.Gateway.InboundDebounceMs)
setBool("gateway.block_reply", cfg.Gateway.BlockReply)
setBool("gateway.tool_status", cfg.Gateway.ToolStatus)
setBool("gateway.team_work_classify", cfg.Gateway.TeamWorkClassify)
setInt("gateway.task_recovery_interval_sec", cfg.Gateway.TaskRecoveryIntervalSec)
// Background workers
@@ -126,6 +127,9 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
setInt("tools.browser.max_pages", cfg.Tools.Browser.MaxPages)
set("tools.browser.cookie_sync_enabled", fmt.Sprintf("%t", cfg.Tools.Browser.CookieSyncEnabled))
// Providers
setInt("providers.request_timeout_sec", cfg.Providers.RequestTimeoutSec)
// TTS
set("tts.provider", cfg.Tts.Provider)
set("tts.auto", cfg.Tts.Auto)
+66
View File
@@ -0,0 +1,66 @@
package cmd
import (
"context"
"log/slog"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/teamworkclassify"
)
type teamWorkGateOutcome struct {
Message string
Directive *agent.TeamWorkDirective
}
func applyTeamWorkGateForInbound(ctx context.Context, deps *ConsumerDeps, msg bus.InboundMessage, sessionKey, agentKey, peerKind string, agentUUID uuid.UUID, skillFilter []string, provider providers.Provider, model string) teamWorkGateOutcome {
out := teamWorkGateOutcome{Message: msg.Content}
if deps == nil || deps.Cfg == nil || deps.Cfg.Gateway.TeamWorkClassify == nil || !*deps.Cfg.Gateway.TeamWorkClassify {
return out
}
if deps.TeamWorkEmbedder == nil {
slog.Info("team_work_classify: skipped; embedding unavailable", "agent", agentKey, "session", sessionKey)
return out
}
if agentUUID == uuid.Nil {
return out
}
mode := agent.ResolveOrchestrationMode(ctx, agentUUID, deps.TeamStore, deps.AgentLinkStore)
if mode == agent.ModeSpawn {
slog.Info("team_work_classify: skipped; no team/delegate capability", "agent", agentKey, "session", sessionKey)
return out
}
if msg.Metadata["run_kind"] != "" || msg.Metadata["delegation_id"] != "" || msg.Metadata["subagent_id"] != "" || bus.IsInternalSender(msg.SenderID) {
return out
}
input := teamworkclassify.BuildInputFromStores(ctx, teamworkclassify.ProfileStores{
Agents: deps.AgentStore,
Teams: deps.TeamStore,
AgentLinks: deps.AgentLinkStore,
}, teamworkclassify.BuildInputOptions{
Mode: teamworkclassify.Mode(mode),
Message: msg.Content,
AgentID: agentUUID,
ToolAllow: msg.ToolAllow,
SkillFilter: skillFilter,
Embedder: deps.TeamWorkEmbedder,
})
result := teamworkclassify.ClassifyWithLLM(ctx, input, provider, model, deps.UsageCaps)
slog.Info("team_work_classify: decision", "agent", agentKey, "session", sessionKey, "mode", mode, "decision", result.Decision, "self_score", result.SelfScore, "collaboration_score", result.CollaborationScore, "reason", result.Reason)
if result.Decision == teamworkclassify.DecisionTeam {
out.Directive = &agent.TeamWorkDirective{
Mode: string(result.Mode),
Source: "llm",
Reason: result.Reason,
OriginalMessage: msg.Content,
RequiredTool: result.RequiredTool,
WorkflowHint: result.WorkflowHint,
}
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
package cmd
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
type teamWorkGateTestEmbedder struct {
called bool
}
func (e *teamWorkGateTestEmbedder) Name() string { return "test-embedder" }
func (e *teamWorkGateTestEmbedder) Model() string { return "test-embedding" }
func (e *teamWorkGateTestEmbedder) Embed(context.Context, []string) ([][]float32, error) {
e.called = true
return [][]float32{{1, 0}}, nil
}
type teamWorkGateTestProvider struct {
called bool
}
func (p *teamWorkGateTestProvider) Name() string { return "test-provider" }
func (p *teamWorkGateTestProvider) DefaultModel() string { return "test-model" }
func (p *teamWorkGateTestProvider) Chat(context.Context, providers.ChatRequest) (*providers.ChatResponse, error) {
p.called = true
return &providers.ChatResponse{Content: `{"decision":"team","mode":"team","required_tool":"team_tasks"}`}, nil
}
func (p *teamWorkGateTestProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) {
p.called = true
return nil, nil
}
func TestApplyTeamWorkGateForInboundSkipsAgentWithoutTeamOrDelegateLink(t *testing.T) {
enabled := true
embedder := &teamWorkGateTestEmbedder{}
provider := &teamWorkGateTestProvider{}
out := applyTeamWorkGateForInbound(context.Background(), &ConsumerDeps{
Cfg: &config.Config{Gateway: config.GatewayConfig{TeamWorkClassify: &enabled}},
TeamWorkEmbedder: embedder,
}, bus.InboundMessage{
Content: "lập kế hoạch content và chiến lược cho chiến dịch mới",
Metadata: map[string]string{},
}, "session:test", "bao-an", "direct", uuid.New(), nil, provider, "test-model")
if out.Message != "lập kế hoạch content và chiến lược cho chiến dịch mới" {
t.Fatalf("Message = %q, want original message", out.Message)
}
if out.Directive != nil {
t.Fatalf("Directive = %+v, want nil", out.Directive)
}
if embedder.called {
t.Fatal("embedder was called even though agent has no team/delegate capability")
}
if provider.called {
t.Fatal("provider was called even though agent has no team/delegate capability")
}
}
+55 -26
View File
@@ -32,6 +32,7 @@ func wireExtraTools(
agentCfg config.AgentDefaults,
globalSkillsDir string,
builtinSkillsDir string,
cronCommandEnabled bool,
) (heartbeatTool *tools.HeartbeatTool, hasMemory bool) {
// web_search: tenant-scoped resolve requires stores + msgBus — register here.
toolsReg.Register(tools.NewWebSearchTool(pgStores.ConfigSecrets, msgBus))
@@ -42,7 +43,10 @@ func wireExtraTools(
toolsReg.Register(tools.NewWaitTool())
// Cron tool (agent-facing)
toolsReg.Register(tools.NewCronTool(pgStores.Cron))
cronTool := tools.NewCronTool(pgStores.Cron)
cronTool.SetProviderStore(pgStores.Providers)
cronTool.SetCommandEnabled(cronCommandEnabled)
toolsReg.Register(cronTool)
slog.Info("cron tool registered")
// Heartbeat tool (agent-facing)
@@ -63,7 +67,15 @@ func wireExtraTools(
toolsReg.Register(tools.NewSendFileTool(workspace, agentCfg.RestrictToWorkspace))
// Group members tool (list members in group chats)
toolsReg.Register(tools.NewListGroupMembersTool())
slog.Info("session + message + send_file tools registered")
// Zalo group list tool (resolve a group's real chat ID from its display name)
toolsReg.Register(tools.NewListGroupsTool())
// Telegram manager tool (admin/forum/message management; gated by tool policy)
// create_forum_topic is kept as a backward-compatible wrapper for topic.create.
toolsReg.Register(tools.NewCreateForumTopicTool(nil))
toolsReg.Register(tools.NewTelegramManagerTool())
// MCP credential manager tool (view and manage per-user MCP credentials)
toolsReg.Register(tools.NewMCPCredentialManagerTool())
slog.Info("session + message + send_file + telegram_manager + mcp_credential_manager tools registered")
// Register legacy tool aliases (backward-compat names from policy.go).
for alias, canonical := range tools.LegacyToolAliases() {
@@ -95,47 +107,31 @@ func wireExtraTools(
if pgStores.Skills != nil {
skillsAllowPaths = append(skillsAllowPaths, pgStores.Skills.Dirs()...)
}
// Expand user-configured allowed paths (for cross-drive access on Windows).
// These paths are validated per-request in resolvePath for tenant isolation.
var userAllowPaths []string
for _, p := range agentCfg.AllowedPaths {
expanded := config.ExpandHome(p)
if expanded != "" {
userAllowPaths = append(userAllowPaths, expanded)
}
}
if readTool, ok := toolsReg.Get("read_file"); ok {
if pa, ok := readTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(filepath.Join(dataDir, "cli-workspaces"))
pa.AllowPaths(userAllowPaths...)
}
}
if listTool, ok := toolsReg.Get("list_files"); ok {
if pa, ok := listTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(userAllowPaths...)
}
}
// Write and edit tools also get user-configured allowed paths for cross-drive access.
if writeTool, ok := toolsReg.Get("write_file"); ok {
if pa, ok := writeTool.(tools.PathAllowable); ok {
pa.AllowPaths(userAllowPaths...)
}
}
if editTool, ok := toolsReg.Get("edit"); ok {
if pa, ok := editTool.(tools.PathAllowable); ok {
pa.AllowPaths(userAllowPaths...)
}
}
if sendFileTool, ok := toolsReg.Get("send_file"); ok {
if pa, ok := sendFileTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(userAllowPaths...)
}
}
// User-configured allowed paths (config agents.defaults.allowed_paths, for
// cross-drive access on Windows and shared dirs outside the workspace).
// Applied via a helper so cmd/gateway.go can re-apply it after
// ApplySystemConfigs overlays system_configs['allowed_paths'] — see
// applyUserAllowedPaths. Paths are validated per-request in resolvePath for
// tenant isolation.
applyUserAllowedPaths(toolsReg, agentCfg.AllowedPaths)
// Memory tools are PG-backed; always available.
hasMemory = true
@@ -160,6 +156,39 @@ func wireExtraTools(
return heartbeatTool, hasMemory
}
// fsAllowPathTools are the filesystem tools that honour user-configured allowed
// paths beyond the agent workspace (config agents.defaults.allowed_paths).
var fsAllowPathTools = []string{"read_file", "list_files", "write_file", "edit", "send_file"}
// applyUserAllowedPaths grants the user-configured allowed paths to the
// filesystem tools. ExpandHome resolves a leading "~".
//
// Called twice during startup: once while tools are wired (from config.json),
// and again from cmd/gateway.go after ApplySystemConfigs overlays
// system_configs['allowed_paths']. Tool wiring runs before that overlay, so
// without the re-apply DB-driven allowed paths never reach the tools — the
// AllowPaths analogue of the rate-limiter re-apply (#1111). Safe to call
// repeatedly: AllowPaths is additive and the prefix check is membership-based,
// so a duplicated prefix is harmless.
func applyUserAllowedPaths(toolsReg *tools.Registry, allowedPaths []string) {
var paths []string
for _, p := range allowedPaths {
if expanded := config.ExpandHome(p); expanded != "" {
paths = append(paths, expanded)
}
}
if len(paths) == 0 {
return
}
for _, name := range fsAllowPathTools {
if t, ok := toolsReg.Get(name); ok {
if pa, ok := t.(tools.PathAllowable); ok {
pa.AllowPaths(paths...)
}
}
}
}
// wireWorkstationTools registers workstation_exec and claude_remote tools (Standard edition only).
// Phase 6: wires the real AllowlistChecker permission check replacing the deny-all sentinel.
// Phase 7: wires the activity sink for exec audit logging.
+86
View File
@@ -0,0 +1,86 @@
package cmd
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// applyUserAllowedPaths must grant the filesystem tools access to paths outside
// the agent workspace. cmd/gateway.go relies on this to re-apply
// system_configs['allowed_paths'] after the overlay (tool wiring runs first) —
// the AllowPaths analogue of the rate-limiter re-apply in #1111.
func TestApplyUserAllowedPaths_GrantsExternalPathToReadFile(t *testing.T) {
ws := t.TempDir()
ext := t.TempDir()
extFile := filepath.Join(ext, "kb.md")
if err := os.WriteFile(extFile, []byte("knowledge"), 0o644); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry()
reg.Register(tools.NewReadFileTool(ws, true))
read, ok := reg.Get("read_file")
if !ok {
t.Fatal("read_file not registered")
}
// Before granting: a path outside the workspace is denied.
if res := read.Execute(context.Background(), map[string]any{"path": extFile}); !res.IsError {
t.Fatalf("expected access denied before allow, got: %s", res.ForLLM)
}
applyUserAllowedPaths(reg, []string{ext})
// After granting: the external path is readable.
if res := read.Execute(context.Background(), map[string]any{"path": extFile}); res.IsError {
t.Fatalf("expected success after allow, got error: %s", res.ForLLM)
}
// The grant is scoped — an unrelated external path stays denied.
other := t.TempDir()
otherFile := filepath.Join(other, "secret.md")
if err := os.WriteFile(otherFile, []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
if res := read.Execute(context.Background(), map[string]any{"path": otherFile}); !res.IsError {
t.Fatalf("expected unrelated external path to stay denied, got: %s", res.ForLLM)
}
}
// Applying twice (initial wiring + the gateway re-apply after ApplySystemConfigs)
// must keep the grant working and must not error.
func TestApplyUserAllowedPaths_RepeatedApplyIsSafe(t *testing.T) {
ws := t.TempDir()
ext := t.TempDir()
extFile := filepath.Join(ext, "kb.md")
if err := os.WriteFile(extFile, []byte("knowledge"), 0o644); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry()
reg.Register(tools.NewReadFileTool(ws, true))
read, ok := reg.Get("read_file")
if !ok {
t.Fatal("read_file not registered")
}
applyUserAllowedPaths(reg, []string{ext}) // initial wiring (from config.json)
applyUserAllowedPaths(reg, []string{ext}) // re-apply after system_configs overlay
if res := read.Execute(context.Background(), map[string]any{"path": extFile}); res.IsError {
t.Fatalf("expected success after repeated allow, got error: %s", res.ForLLM)
}
}
// An empty allow list is a no-op and must not panic (the common case when no
// allowed_paths are configured).
func TestApplyUserAllowedPaths_EmptyIsNoop(t *testing.T) {
reg := tools.NewRegistry()
reg.Register(tools.NewReadFileTool(t.TempDir(), true))
applyUserAllowedPaths(reg, nil)
applyUserAllowedPaths(reg, []string{})
}
+4
View File
@@ -36,6 +36,10 @@ func wireVault(stores *store.Stores, toolsReg *tools.Registry, workspace string,
if stores.Episodic != nil {
vaultReadTool.SetEpisodicStore(stores.Episodic)
}
// Tenant slug fallback for runs whose context carries no slug (channels, cron).
if stores.Tenants != nil {
vaultReadTool.SetTenantStore(stores.Tenants)
}
toolsReg.Register(vaultReadTool)
// Build VaultSearchService: fan-out across vault + episodic + KG.
+17 -27
View File
@@ -9,11 +9,11 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/golang-migrate/migrate/v4/source"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
@@ -39,36 +39,26 @@ func resolveMigrationsDir() string {
return filepath.Join(filepath.Dir(exe), "migrations")
}
// absoluteToFileURI formats an already-absolute path into an RFC 8089-compliant
// file:// URL. golang-migrate's file source driver rejects Windows paths like
// "file://F:\\project\\migrations" because "F" is parsed as the host and
// ":\\..." as the port. The fix is shape-driven (presence of a drive-letter
// colon at index 1), so no runtime.GOOS branch is needed — the same code is
// correct for POSIX inputs ("/app/x" → "file:///app/x") and for Windows inputs
// on any OS ("F:\\x" → "file:///F:/x"). strings.ReplaceAll covers the case
// where a Windows path is seen on a non-Windows runner (filepath.ToSlash is a
// no-op outside Windows).
func absoluteToFileURI(abs string) string {
abs = strings.ReplaceAll(filepath.ToSlash(abs), `\`, `/`)
if len(abs) >= 2 && abs[1] == ':' {
abs = "/" + abs
}
return "file://" + abs
}
// migrationsSourceURL resolves dir to an absolute path and formats it for
// golang-migrate's file source driver.
func migrationsSourceURL(dir string) string {
abs, err := filepath.Abs(dir)
// newMigrationSource opens the migrations directory as a golang-migrate source.
// It uses an iofs source over os.DirFS rather than a file:// URL: golang-migrate's
// file source driver mis-parses Windows absolute paths — the drive-letter URL
// "file:///D:/..." fails with "open ." errors — whereas os.DirFS uses native OS
// path handling and behaves identically on every platform.
func newMigrationSource() (source.Driver, error) {
dir := resolveMigrationsDir()
src, err := iofs.New(os.DirFS(dir), ".")
if err != nil {
abs = dir
return nil, fmt.Errorf("open migrations dir %q: %w", dir, err)
}
return absoluteToFileURI(abs)
return src, nil
}
func newMigrator(dsn string) (*migrate.Migrate, error) {
dir := resolveMigrationsDir()
m, err := migrate.New(migrationsSourceURL(dir), dsn)
src, err := newMigrationSource()
if err != nil {
return nil, err
}
m, err := migrate.NewWithSourceInstance("iofs", src, dsn)
if err != nil {
return nil, fmt.Errorf("create migrator: %w", err)
}
+18 -31
View File
@@ -2,41 +2,28 @@ package cmd
import (
"path/filepath"
"strings"
"testing"
)
func TestAbsoluteToFileURI(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"posix absolute", "/app/migrations", "file:///app/migrations"},
// Windows drive-letter path with backslashes: the exact shape
// golang-migrate needs. Before the fix, "file://F:\\..." was parsed
// with "F" as host and ":\\..." as port → "invalid port" error.
{"windows backslash", `F:\project\goclaw\migrations`, "file:///F:/project/goclaw/migrations"},
{"windows mixed separators", `C:/already/forward`, "file:///C:/already/forward"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := absoluteToFileURI(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
// TestNewMigrationSource_LoadsMigrations guards against the Windows regression
// where golang-migrate's file:// source driver failed to open an absolute
// drive-letter path (e.g. "file:///D:/..." → "open ." error). The iofs source
// over os.DirFS must load the real migrations directory on every platform.
func TestNewMigrationSource_LoadsMigrations(t *testing.T) {
migrationsDir = filepath.Join("..", "migrations")
t.Cleanup(func() { migrationsDir = "" })
// TestMigrationsSourceURLRelative verifies the helper resolves a relative
// input via filepath.Abs before formatting — exact output depends on CWD,
// so we only assert invariants that must hold on every runner.
func TestMigrationsSourceURLRelative(t *testing.T) {
got := migrationsSourceURL("migrations")
if !strings.HasPrefix(got, "file://") {
t.Fatalf("missing file:// prefix: %q", got)
src, err := newMigrationSource()
if err != nil {
t.Fatalf("newMigrationSource: %v", err)
}
if !strings.Contains(filepath.ToSlash(got), "/migrations") {
t.Errorf("missing /migrations segment: %q", got)
defer src.Close()
first, err := src.First()
if err != nil {
t.Fatalf("read first migration: %v", err)
}
if first != 1 {
t.Errorf("first migration version = %d, want 1", first)
}
}
+2
View File
@@ -7,6 +7,7 @@ import (
"os"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
@@ -27,6 +28,7 @@ func testPostgresConnection(dsn string) error {
// defaultPlaceholderProviders defines disabled placeholder providers seeded for
// UI discoverability. Users can later enable and configure them via the dashboard.
var defaultPlaceholderProviders = []store.LLMProviderData{
{Name: "aimlapi", DisplayName: "AI/ML API", ProviderType: store.ProviderAIMLAPI, APIBase: providers.AIMLAPIDefaultAPIBase, Enabled: false},
{Name: "api_route", DisplayName: "API Route", ProviderType: store.ProviderAPIRoute, APIBase: store.APIRouteDefaultAPIBase, Enabled: false},
{Name: "openrouter", DisplayName: "OpenRouter", ProviderType: store.ProviderOpenRouter, APIBase: "https://openrouter.ai/api/v1", Enabled: false},
{Name: "synthetic", DisplayName: "Synthetic", ProviderType: store.ProviderOpenAICompat, APIBase: "https://api.synthetic.new/openai/v1", Enabled: false},
+5 -2
View File
@@ -124,10 +124,11 @@ func runProvidersAdd() {
typeOptions := []SelectOption[string]{
{"Anthropic", "anthropic"},
{"OpenAI", "openai"},
{"Atlas Cloud", "atlascloud"},
{"API Route", "api_route"},
{"OpenRouter", "openrouter"},
{"DashScope (Alibaba)", "dashscope"},
{"OpenAI-compatible", "openai-compat"},
{"OpenAI-compatible", "openai_compat"},
}
providerType, err := promptSelect("Provider type", typeOptions, 0)
if err != nil {
@@ -152,7 +153,7 @@ func runProvidersAdd() {
// Step 4: Base URL (pre-fill per type, editable)
defaultURL := defaultBaseURL(providerType)
baseURL := ""
if providerType == "openai-compat" || providerType == "api_route" {
if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" {
baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL)
if err != nil {
fmt.Println("Cancelled.")
@@ -312,6 +313,8 @@ func defaultBaseURL(providerType string) string {
return "https://api.anthropic.com"
case "openai":
return "https://api.openai.com/v1"
case "atlascloud":
return "https://api.atlascloud.ai/v1"
case "api_route":
return store.APIRouteDefaultAPIBase
case "openrouter":
+6 -3
View File
@@ -47,10 +47,11 @@ func addProvider() {
typeOptions := []SelectOption[string]{
{"Anthropic", "anthropic"},
{"OpenAI", "openai"},
{"Atlas Cloud", "atlascloud"},
{"API Route", "api_route"},
{"OpenRouter", "openrouter"},
{"DashScope (Alibaba)", "dashscope"},
{"OpenAI-compatible", "openai-compat"},
{"OpenAI-compatible", "openai_compat"},
}
providerType, err := promptSelect("Provider type", typeOptions, 0)
if err != nil {
@@ -69,9 +70,11 @@ func addProvider() {
}
baseURL := ""
if providerType == "openai-compat" || providerType == "api_route" {
if providerType == "openai_compat" || providerType == "atlascloud" || providerType == "api_route" {
defaultURL := ""
if providerType == "api_route" {
if providerType == "atlascloud" {
defaultURL = "https://api.atlascloud.ai/v1"
} else if providerType == "api_route" {
defaultURL = "https://global.api-route.com/v1"
}
baseURL, err = promptString("Base URL", "e.g. https://api.example.com/v1", defaultURL)