feat(config): add workspace-local config.json overlay for per-tenant isolation

Support per-workspace config overrides via --config-dir (CLI) or configDir
(webhook), enabling different API keys, models, and agent settings per tenant.

- Add LoadWorkspaceConfig/MergeWorkspaceConfig with raw JSON overlay to
  preserve unmentioned bool fields (prevents clobbering tool enabled flags)
- Add Config.Clone() for safe per-request config copies in gateway mode
- Gateway: per-request provider creation from workspace config, with
  effProvider/effModel threaded through all LLM call sites and summarization
- CLI: workspace config merged before provider creation, CLI flags win
- MagicForm: replace inline bootstrap fields with configDir path;
  agent loop copies bootstrap files and loads config.json from configDir
- Fix --session flag: format as agent:main:cli:{key} so router honors it
- Fix cron session key: format as agent:main:cron:{id} for isolation
- Add shared CopyBootstrapFiles helper in pkg/agent/bootstrap.go
- Add config/workspace.config.example.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
admin-mf 2026-03-06 01:49:36 -06:00
parent 6ab2d70348
commit 2da1f1f2d7
8 changed files with 332 additions and 59 deletions

View file

@ -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)")

View file

@ -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
}

View file

@ -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
}
}
}

29
pkg/agent/bootstrap.go Normal file
View file

@ -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()})
}
}
}

View file

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

View file

@ -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()})
}
}

View file

@ -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
}

View file

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