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:
parent
6ab2d70348
commit
2da1f1f2d7
8 changed files with 332 additions and 59 deletions
|
|
@ -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)")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
30
config/workspace.config.example.json
Normal file
30
config/workspace.config.example.json
Normal 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
29
pkg/agent/bootstrap.go
Normal 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()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue