diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go index 8fe683936..0a99db39b 100644 --- a/cmd/picoclaw/internal/agent/command.go +++ b/cmd/picoclaw/internal/agent/command.go @@ -29,12 +29,12 @@ func NewAgentCommand() *cobra.Command { cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)") - cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key") + cmd.Flags().StringVarP(&sessionKey, "session", "s", "", "Session key for conversation isolation (e.g. stackId:conversationId)") cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") // Workspace and config overrides cmd.Flags().StringVar(&workspace, "workspace", "", "Override agent workspace directory") - cmd.Flags().StringVar(&configDir, "config-dir", "", "Directory containing bootstrap files (AGENTS.md, IDENTITY.md, SOUL.md, USER.md) to copy into workspace") + cmd.Flags().StringVar(&configDir, "config-dir", "", "Directory containing config.json (model/agent/tool overrides) and bootstrap files (AGENTS.md, IDENTITY.md, SOUL.md, USER.md)") cmd.Flags().StringVar(&tools, "tools", "", "Comma-separated tool allowlist (only these tools enabled)") cmd.Flags().StringVar(&skills, "skills", "", "Comma-separated skill filter (only these skills loaded)") diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 6bf69be58..db26e99ed 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -22,7 +22,9 @@ import ( func agentCmd(message, sessionKey, model string, debug bool, workspace, configDir, toolsFlag, skillsFlag string) error { if sessionKey == "" { - sessionKey = "cli:default" + sessionKey = "agent:main:cli:default" + } else if !strings.HasPrefix(sessionKey, "agent:") { + sessionKey = "agent:main:cli:" + sessionKey } if debug { @@ -35,6 +37,16 @@ func agentCmd(message, sessionKey, model string, debug bool, return fmt.Errorf("error loading config: %w", err) } + // Apply workspace-local config overrides from config-dir + if configDir != "" { + wc, err := config.LoadWorkspaceConfig(configDir) + if err != nil { + return fmt.Errorf("error loading workspace config from %s: %w", configDir, err) + } + cfg.MergeWorkspaceConfig(wc) + } + + // CLI flags win over workspace config if model != "" { cfg.Agents.Defaults.ModelName = model } diff --git a/config/workspace.config.example.json b/config/workspace.config.example.json new file mode 100644 index 000000000..cc9944db9 --- /dev/null +++ b/config/workspace.config.example.json @@ -0,0 +1,30 @@ +{ + "_comment": "Workspace-local config override. Place as config.json in a config directory passed via --config-dir (CLI) or configDir (webhook). Only the fields below are honored; gateway, heartbeat, devices, and providers (legacy) are ignored.", + "model_list": [ + { + "model_name": "main", + "model": "anthropic/claude-sonnet-4.6", + "api_key": "sk-ant-your-key", + "api_base": "https://api.anthropic.com/v1" + } + ], + "agents": { + "defaults": { + "model_name": "main", + "max_tokens": 4096, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "session": { + "dm_scope": "per-channel-peer" + }, + "tools": { + "exec": { + "enabled": false + }, + "web": { + "enabled": true + } + } +} diff --git a/pkg/agent/bootstrap.go b/pkg/agent/bootstrap.go new file mode 100644 index 000000000..fbc69d61d --- /dev/null +++ b/pkg/agent/bootstrap.go @@ -0,0 +1,29 @@ +package agent + +import ( + "os" + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// bootstrapFiles are the recognized bootstrap file names copied from a config +// directory into the agent workspace. +var bootstrapFiles = []string{"AGENTS.md", "IDENTITY.md", "SOUL.md", "USER.md"} + +// CopyBootstrapFiles copies recognized bootstrap files from srcDir into dstDir. +// Missing files in srcDir are silently skipped. +func CopyBootstrapFiles(srcDir, dstDir string) { + for _, filename := range bootstrapFiles { + srcPath := filepath.Join(srcDir, filename) + data, err := os.ReadFile(srcPath) + if err != nil { + continue // file not present, skip + } + dstPath := filepath.Join(dstDir, filename) + if err := os.WriteFile(dstPath, data, 0o644); err != nil { + logger.WarnCF("agent", "Failed to write bootstrap file", + map[string]any{"path": dstPath, "error": err.Error()}) + } + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 4c84fbff3..4d72935cd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -64,6 +64,7 @@ type processOptions struct { SendResponse bool // Whether to send response via bus NoHistory bool // If true, don't load session history (for heartbeat) WorkspaceOverride string // If set, use this workspace instead of agent.Workspace + ConfigDir string // If set, config directory for workspace-local overrides AllowedTools []string // If non-empty, only these tools are active for this request AllowedSkills []string // If non-empty, only these skills are loaded for this request @@ -72,6 +73,12 @@ type processOptions struct { // retry) MUST use these instead of agent.Sessions / agent.ContextBuilder. effSessions *session.SessionManager effContextBuilder *ContextBuilder + + // effProvider and effModel are set by runAgentLoop when a workspace config + // provides per-request provider overrides. All LLM call sites (runLLMIteration, + // summarization) MUST use these instead of agent.Provider / agent.Model. + effProvider providers.LLMProvider + effModel string } const ( @@ -628,6 +635,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) // Extract overrides from metadata (used by magicform channel / gateway mode) workspaceOverride := msg.Metadata["workspace_override"] + configDir := msg.Metadata["config_dir"] var allowedTools, allowedSkills []string if v := msg.Metadata["allowed_tools"]; v != "" { @@ -655,6 +663,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) EnableSummary: true, SendResponse: false, WorkspaceOverride: workspaceOverride, + ConfigDir: configDir, AllowedTools: allowedTools, AllowedSkills: allowedSkills, }) @@ -787,6 +796,39 @@ func (al *AgentLoop) runAgentLoop( effContextBuilder = NewContextBuilder(wp) } + // Copy bootstrap files from config-dir to workspace + if opts.ConfigDir != "" && opts.WorkspaceOverride != "" { + CopyBootstrapFiles(opts.ConfigDir, opts.WorkspaceOverride) + } + + // Load workspace-local config.json for per-request overrides + configSource := opts.ConfigDir + if configSource == "" { + configSource = opts.WorkspaceOverride // fallback: check workspace itself + } + if configSource != "" { + if wc, err := config.LoadWorkspaceConfig(configSource); err != nil { + logger.WarnCF("agent", "Failed to load workspace config", + map[string]any{"path": configSource, "error": err.Error()}) + } else if wc != nil { + tmpCfg := al.cfg.Clone() + tmpCfg.MergeWorkspaceConfig(wc) + if tmpCfg.Agents.Defaults.GetModelName() == "" { + tmpCfg.Agents.Defaults.ModelName = agent.Model + } + if provider, modelID, err := providers.CreateProvider(tmpCfg); err != nil { + logger.ErrorCF("agent", "Failed to create workspace provider", + map[string]any{"path": configSource, "error": err.Error()}) + } else { + opts.effProvider = provider + opts.effModel = modelID + if sp, ok := provider.(providers.StatefulProvider); ok { + defer sp.Close() + } + } + } + } + // Apply skills filter unconditionally — works with or without workspace override if len(opts.AllowedSkills) > 0 { effContextBuilder.SetSkillsFilter(opts.AllowedSkills) @@ -839,7 +881,7 @@ func (al *AgentLoop) runAgentLoop( // 6. Optional: summarization if opts.EnableSummary { - al.maybeSummarizeWith(effSessions, agent, opts.SessionKey, opts.Channel, opts.ChatID) + al.maybeSummarizeWith(effSessions, agent, opts.SessionKey, opts.Channel, opts.ChatID, opts.effProvider, opts.effModel) } // 7. Optional: send response via bus @@ -930,6 +972,12 @@ func (al *AgentLoop) runLLMIteration( iteration := 0 var finalContent string + // Resolve effective provider — workspace config overrides win + effProvider := agent.Provider + if opts.effProvider != nil { + effProvider = opts.effProvider + } + // Determine effective model tier for this conversation turn. // selectCandidates evaluates routing once and the decision is sticky for // all tool-follow-up iterations within the same turn so that a multi-step @@ -995,7 +1043,7 @@ func (al *AgentLoop) runLLMIteration( // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, // so checking != ThinkingOff is sufficient. if agent.ThinkingLevel != ThinkingOff { - if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + if tc, ok := effProvider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { llmOpts["thinking_level"] = string(agent.ThinkingLevel) } else { logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", @@ -1009,7 +1057,7 @@ func (al *AgentLoop) runLLMIteration( ctx, activeCandidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts) + return effProvider.Chat(ctx, messages, providerToolDefs, model, llmOpts) }, ) if fbErr != nil { @@ -1025,7 +1073,7 @@ func (al *AgentLoop) runLLMIteration( } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) + return effProvider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) } // Retry loop for context/token errors @@ -1352,11 +1400,12 @@ func (al *AgentLoop) selectCandidates( // maybeSummarize triggers summarization if the session history exceeds thresholds. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { - al.maybeSummarizeWith(agent.Sessions, agent, sessionKey, channel, chatID) + al.maybeSummarizeWith(agent.Sessions, agent, sessionKey, channel, chatID, nil, "") } -// maybeSummarizeWith is like maybeSummarize but accepts an explicit SessionManager. -func (al *AgentLoop) maybeSummarizeWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey, channel, chatID string) { +// maybeSummarizeWith is like maybeSummarize but accepts an explicit SessionManager +// and optional per-request provider/model overrides. +func (al *AgentLoop) maybeSummarizeWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey, channel, chatID string, effProvider providers.LLMProvider, effModel string) { newHistory := sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 @@ -1367,7 +1416,7 @@ func (al *AgentLoop) maybeSummarizeWith(sessions *session.SessionManager, agent go func() { defer al.summarizing.Delete(summarizeKey) logger.Debug("Memory threshold reached. Optimizing conversation history...") - al.summarizeSessionWith(sessions, agent, sessionKey) + al.summarizeSessionWith(sessions, agent, sessionKey, effProvider, effModel) }() } } @@ -1521,14 +1570,25 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { // summarizeSession summarizes the conversation history for a session. func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { - al.summarizeSessionWith(agent.Sessions, agent, sessionKey) + al.summarizeSessionWith(agent.Sessions, agent, sessionKey, nil, "") } -// summarizeSessionWith is like summarizeSession but accepts an explicit SessionManager. -func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey string) { +// summarizeSessionWith is like summarizeSession but accepts an explicit SessionManager +// and optional per-request provider/model overrides. +func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey string, effProvider providers.LLMProvider, effModel string) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() + // Resolve effective provider/model + sumProvider := agent.Provider + sumModel := agent.Model + if effProvider != nil { + sumProvider = effProvider + } + if effModel != "" { + sumModel = effModel + } + history := sessions.GetHistory(sessionKey) summary := sessions.GetSummary(sessionKey) @@ -1567,19 +1627,19 @@ func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agen part1 := validMessages[:mid] part2 := validMessages[mid:] - s1, _ := al.summarizeBatch(ctx, agent, part1, "") - s2, _ := al.summarizeBatch(ctx, agent, part2, "") + s1, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part1, "") + s2, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part2, "") mergePrompt := fmt.Sprintf( "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2, ) - resp, err := agent.Provider.Chat( + resp, err := sumProvider.Chat( ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, - agent.Model, + sumModel, map[string]any{ "max_tokens": 1024, "temperature": 0.3, @@ -1592,7 +1652,7 @@ func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agen finalSummary = s1 + " " + s2 } } else { - finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) + finalSummary, _ = al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, validMessages, summary) } if omitted && finalSummary != "" { @@ -1606,10 +1666,12 @@ func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agen } } -// summarizeBatch summarizes a batch of messages. +// summarizeBatch summarizes a batch of messages using the given provider/model. func (al *AgentLoop) summarizeBatch( ctx context.Context, - agent *AgentInstance, + provider providers.LLMProvider, + model string, + agentID string, batch []providers.Message, existingSummary string, ) (string, error) { @@ -1628,15 +1690,15 @@ func (al *AgentLoop) summarizeBatch( } prompt := sb.String() - response, err := agent.Provider.Chat( + response, err := provider.Chat( ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, - agent.Model, + model, map[string]any{ "max_tokens": 1024, "temperature": 0.3, - "prompt_cache_key": agent.ID, + "prompt_cache_key": agentID, }, ) if err != nil { diff --git a/pkg/channels/magicform/magicform.go b/pkg/channels/magicform/magicform.go index 145189e36..f929d7c4b 100644 --- a/pkg/channels/magicform/magicform.go +++ b/pkg/channels/magicform/magicform.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "os" "path/filepath" "strings" "sync" @@ -26,15 +25,10 @@ type WebhookPayload struct { ConversationID string `json:"conversationId"` UserID string `json:"userId"` Message string `json:"message"` - Workspace string `json:"workspace"` // e.g. "/data/workspaces/{stackId}/{conversationId}" + Workspace string `json:"workspace"` // e.g. "s1/c1" — agent working directory (relative to workspace_root) + ConfigDir string `json:"configDir,omitempty"` // e.g. "s1/config" — pre-provisioned config directory (relative to workspace_root) CallbackURL string `json:"callbackUrl"` - // Config overrides (written as bootstrap files into workspace) - AgentInstructions string `json:"agentInstructions,omitempty"` // → AGENTS.md - AgentIdentity string `json:"agentIdentity,omitempty"` // → IDENTITY.md - AgentPersonality string `json:"agentPersonality,omitempty"` // → SOUL.md - UserContext string `json:"userContext,omitempty"` // → USER.md - // Tool/skill filtering AllowedTools []string `json:"allowedTools,omitempty"` // Tool allowlist (empty = all) AllowedSkills []string `json:"allowedSkills,omitempty"` // Skill filter (empty = all) @@ -181,6 +175,16 @@ func (c *MagicFormChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { payload.Workspace = resolved } + // Validate configDir path against workspace_root + if payload.ConfigDir != "" { + resolved, err := c.resolveWorkspace(payload.ConfigDir) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid configDir: %v", err), http.StatusBadRequest) + return + } + payload.ConfigDir = resolved + } + // Return 200 immediately, process asynchronously w.WriteHeader(http.StatusOK) @@ -276,22 +280,11 @@ func (c *MagicFormChannel) processWebhook(ctx context.Context, p WebhookPayload) // Workspace override — agent loop will pick this up if p.Workspace != "" { metadata["workspace_override"] = p.Workspace + } - // Write bootstrap files to the workspace before agent processes - if err := os.MkdirAll(p.Workspace, 0o755); err != nil { - logger.ErrorCF("magicform", "Failed to create workspace directory", - map[string]any{ - "workspace": p.Workspace, - "stack_id": p.StackID, - "conversation_id": p.ConversationID, - "error": err.Error(), - }) - } - - writeBootstrapFile(p.Workspace, "AGENTS.md", p.AgentInstructions) - writeBootstrapFile(p.Workspace, "IDENTITY.md", p.AgentIdentity) - writeBootstrapFile(p.Workspace, "SOUL.md", p.AgentPersonality) - writeBootstrapFile(p.Workspace, "USER.md", p.UserContext) + // Config directory — agent loop reads config.json and copies bootstrap files + if p.ConfigDir != "" { + metadata["config_dir"] = p.ConfigDir } // Tool/skill filtering — passed via metadata, picked up by agent loop @@ -426,14 +419,3 @@ func (c *MagicFormChannel) cleanupLoop() { } } -// writeBootstrapFile writes content to a file in the workspace if non-empty. -func writeBootstrapFile(workspace, filename, content string) { - if content == "" { - return - } - path := filepath.Join(workspace, filename) - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - logger.ErrorCF("magicform", "Failed to write bootstrap file", - map[string]any{"path": path, "error": err.Error()}) - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 2e1ec22e0..916a1dd98 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sync/atomic" "github.com/caarlos0/env/v11" @@ -935,3 +936,160 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return true } } + +// WorkspaceConfig wraps a parsed Config along with the raw JSON bytes so that +// merging can distinguish "field not present" from "field is zero-valued". +type WorkspaceConfig struct { + Config *Config + rawJSON json.RawMessage +} + +// LoadWorkspaceConfig loads a workspace-local config.json from the given directory. +// Returns nil, nil if the file does not exist. +func LoadWorkspaceConfig(dir string) (*WorkspaceConfig, error) { + path := filepath.Join(dir, "config.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading workspace config %s: %w", path, err) + } + + var wc Config + if err := json.Unmarshal(data, &wc); err != nil { + return nil, fmt.Errorf("parsing workspace config %s: %w", path, err) + } + + for i := range wc.ModelList { + if err := wc.ModelList[i].Validate(); err != nil { + return nil, fmt.Errorf("workspace config model_list[%d]: %w", i, err) + } + } + + return &WorkspaceConfig{Config: &wc, rawJSON: data}, nil +} + +// Clone returns a deep copy of the config via JSON round-trip. +func (c *Config) Clone() *Config { + data, err := json.Marshal(c) + if err != nil { + // Should never happen with a valid Config + return DefaultConfig() + } + var clone Config + if err := json.Unmarshal(data, &clone); err != nil { + return DefaultConfig() + } + return &clone +} + +// MergeWorkspaceConfig overlays allowed fields from a workspace config onto this config. +// Fields NOT honored (infrastructure-level): Gateway, Heartbeat, Devices, Providers. +func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) { + if wc == nil || wc.Config == nil { + return + } + src := wc.Config + + // model_list: replace if workspace has entries + if len(src.ModelList) > 0 { + c.ModelList = src.ModelList + } + + // agents.defaults: merge non-zero fields + mergeAgentDefaults(&c.Agents.Defaults, &src.Agents.Defaults) + + // agents.list: replace if workspace has entries + if len(src.Agents.List) > 0 { + c.Agents.List = src.Agents.List + } + + // tools & channels: use raw JSON overlay so that only keys actually present + // in the workspace file are applied (avoids clobbering bool fields with false). + mergeRawJSONField(wc.rawJSON, "tools", &c.Tools) + mergeRawJSONField(wc.rawJSON, "channels", &c.Channels) + + // bindings: replace if workspace has entries + if len(src.Bindings) > 0 { + c.Bindings = src.Bindings + } + + // session: merge non-zero fields (prevents cross-tenant identity leakage) + mergeSessionConfig(&c.Session, &src.Session) +} + +// mergeAgentDefaults copies non-zero fields from src into dst. +func mergeAgentDefaults(dst, src *AgentDefaults) { + if src.Workspace != "" { + dst.Workspace = src.Workspace + } + if src.RestrictToWorkspace { + dst.RestrictToWorkspace = true + } + if src.AllowReadOutsideWorkspace { + dst.AllowReadOutsideWorkspace = true + } + if src.Provider != "" { + dst.Provider = src.Provider + } + if src.ModelName != "" { + dst.ModelName = src.ModelName + } + if src.Model != "" { + dst.Model = src.Model + } + if len(src.ModelFallbacks) > 0 { + dst.ModelFallbacks = src.ModelFallbacks + } + if src.ImageModel != "" { + dst.ImageModel = src.ImageModel + } + if len(src.ImageModelFallbacks) > 0 { + dst.ImageModelFallbacks = src.ImageModelFallbacks + } + if src.MaxTokens > 0 { + dst.MaxTokens = src.MaxTokens + } + if src.Temperature != nil { + dst.Temperature = src.Temperature + } + if src.MaxToolIterations > 0 { + dst.MaxToolIterations = src.MaxToolIterations + } + if src.SummarizeMessageThreshold > 0 { + dst.SummarizeMessageThreshold = src.SummarizeMessageThreshold + } + if src.SummarizeTokenPercent > 0 { + dst.SummarizeTokenPercent = src.SummarizeTokenPercent + } + if src.MaxMediaSize > 0 { + dst.MaxMediaSize = src.MaxMediaSize + } +} + +// mergeSessionConfig copies non-zero fields from src into dst. +func mergeSessionConfig(dst, src *SessionConfig) { + if src.DMScope != "" { + dst.DMScope = src.DMScope + } + if len(src.IdentityLinks) > 0 { + dst.IdentityLinks = src.IdentityLinks + } +} + +// mergeRawJSONField extracts a top-level key from raw JSON and unmarshals it +// onto dst. Because we use the original JSON bytes, only keys actually present +// in the workspace file are applied — zero-valued fields (e.g. bool false) that +// were never in the file are not included. +func mergeRawJSONField[T any](rawJSON json.RawMessage, key string, dst *T) { + var top map[string]json.RawMessage + if err := json.Unmarshal(rawJSON, &top); err != nil { + return + } + fieldData, ok := top[key] + if !ok || string(fieldData) == "null" { + return + } + json.Unmarshal(fieldData, dst) //nolint:errcheck +} diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6af0aa9e1..435c55a3a 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -315,7 +315,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } // For deliver=false, process through agent (for complex tasks) - sessionKey := fmt.Sprintf("cron-%s", job.ID) + sessionKey := fmt.Sprintf("agent:main:cron:%s", job.ID) // Call agent with job's message response, err := t.executor.ProcessDirectWithChannel(