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().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
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")
|
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
||||||
|
|
||||||
// Workspace and config overrides
|
// Workspace and config overrides
|
||||||
cmd.Flags().StringVar(&workspace, "workspace", "", "Override agent workspace directory")
|
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(&tools, "tools", "", "Comma-separated tool allowlist (only these tools enabled)")
|
||||||
cmd.Flags().StringVar(&skills, "skills", "", "Comma-separated skill filter (only these skills loaded)")
|
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,
|
func agentCmd(message, sessionKey, model string, debug bool,
|
||||||
workspace, configDir, toolsFlag, skillsFlag string) error {
|
workspace, configDir, toolsFlag, skillsFlag string) error {
|
||||||
if sessionKey == "" {
|
if sessionKey == "" {
|
||||||
sessionKey = "cli:default"
|
sessionKey = "agent:main:cli:default"
|
||||||
|
} else if !strings.HasPrefix(sessionKey, "agent:") {
|
||||||
|
sessionKey = "agent:main:cli:" + sessionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
|
|
@ -35,6 +37,16 @@ func agentCmd(message, sessionKey, model string, debug bool,
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
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 != "" {
|
if model != "" {
|
||||||
cfg.Agents.Defaults.ModelName = 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
|
SendResponse bool // Whether to send response via bus
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
WorkspaceOverride string // If set, use this workspace instead of agent.Workspace
|
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
|
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
|
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.
|
// retry) MUST use these instead of agent.Sessions / agent.ContextBuilder.
|
||||||
effSessions *session.SessionManager
|
effSessions *session.SessionManager
|
||||||
effContextBuilder *ContextBuilder
|
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 (
|
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)
|
// Extract overrides from metadata (used by magicform channel / gateway mode)
|
||||||
workspaceOverride := msg.Metadata["workspace_override"]
|
workspaceOverride := msg.Metadata["workspace_override"]
|
||||||
|
configDir := msg.Metadata["config_dir"]
|
||||||
|
|
||||||
var allowedTools, allowedSkills []string
|
var allowedTools, allowedSkills []string
|
||||||
if v := msg.Metadata["allowed_tools"]; v != "" {
|
if v := msg.Metadata["allowed_tools"]; v != "" {
|
||||||
|
|
@ -655,6 +663,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
WorkspaceOverride: workspaceOverride,
|
WorkspaceOverride: workspaceOverride,
|
||||||
|
ConfigDir: configDir,
|
||||||
AllowedTools: allowedTools,
|
AllowedTools: allowedTools,
|
||||||
AllowedSkills: allowedSkills,
|
AllowedSkills: allowedSkills,
|
||||||
})
|
})
|
||||||
|
|
@ -787,6 +796,39 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
effContextBuilder = NewContextBuilder(wp)
|
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
|
// Apply skills filter unconditionally — works with or without workspace override
|
||||||
if len(opts.AllowedSkills) > 0 {
|
if len(opts.AllowedSkills) > 0 {
|
||||||
effContextBuilder.SetSkillsFilter(opts.AllowedSkills)
|
effContextBuilder.SetSkillsFilter(opts.AllowedSkills)
|
||||||
|
|
@ -839,7 +881,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
|
|
||||||
// 6. Optional: summarization
|
// 6. Optional: summarization
|
||||||
if opts.EnableSummary {
|
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
|
// 7. Optional: send response via bus
|
||||||
|
|
@ -930,6 +972,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
iteration := 0
|
iteration := 0
|
||||||
var finalContent string
|
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.
|
// Determine effective model tier for this conversation turn.
|
||||||
// selectCandidates evaluates routing once and the decision is sticky for
|
// selectCandidates evaluates routing once and the decision is sticky for
|
||||||
// all tool-follow-up iterations within the same turn so that a multi-step
|
// 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,
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
||||||
// so checking != ThinkingOff is sufficient.
|
// so checking != ThinkingOff is sufficient.
|
||||||
if agent.ThinkingLevel != ThinkingOff {
|
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)
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
||||||
} else {
|
} else {
|
||||||
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||||
|
|
@ -1009,7 +1057,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
ctx,
|
ctx,
|
||||||
activeCandidates,
|
activeCandidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
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 {
|
if fbErr != nil {
|
||||||
|
|
@ -1025,7 +1073,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
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
|
// Retry loop for context/token errors
|
||||||
|
|
@ -1352,11 +1400,12 @@ func (al *AgentLoop) selectCandidates(
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
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.
|
// maybeSummarizeWith is like maybeSummarize but accepts an explicit SessionManager
|
||||||
func (al *AgentLoop) maybeSummarizeWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey, channel, chatID string) {
|
// 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)
|
newHistory := sessions.GetHistory(sessionKey)
|
||||||
tokenEstimate := al.estimateTokens(newHistory)
|
tokenEstimate := al.estimateTokens(newHistory)
|
||||||
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
|
||||||
|
|
@ -1367,7 +1416,7 @@ func (al *AgentLoop) maybeSummarizeWith(sessions *session.SessionManager, agent
|
||||||
go func() {
|
go func() {
|
||||||
defer al.summarizing.Delete(summarizeKey)
|
defer al.summarizing.Delete(summarizeKey)
|
||||||
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
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.
|
// summarizeSession summarizes the conversation history for a session.
|
||||||
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
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.
|
// summarizeSessionWith is like summarizeSession but accepts an explicit SessionManager
|
||||||
func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agent *AgentInstance, sessionKey string) {
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
defer cancel()
|
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)
|
history := sessions.GetHistory(sessionKey)
|
||||||
summary := sessions.GetSummary(sessionKey)
|
summary := sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
|
|
@ -1567,19 +1627,19 @@ func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agen
|
||||||
part1 := validMessages[:mid]
|
part1 := validMessages[:mid]
|
||||||
part2 := validMessages[mid:]
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
|
s1, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part1, "")
|
||||||
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
|
s2, _ := al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, part2, "")
|
||||||
|
|
||||||
mergePrompt := fmt.Sprintf(
|
mergePrompt := fmt.Sprintf(
|
||||||
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
|
||||||
s1,
|
s1,
|
||||||
s2,
|
s2,
|
||||||
)
|
)
|
||||||
resp, err := agent.Provider.Chat(
|
resp, err := sumProvider.Chat(
|
||||||
ctx,
|
ctx,
|
||||||
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
||||||
nil,
|
nil,
|
||||||
agent.Model,
|
sumModel,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
|
|
@ -1592,7 +1652,7 @@ func (al *AgentLoop) summarizeSessionWith(sessions *session.SessionManager, agen
|
||||||
finalSummary = s1 + " " + s2
|
finalSummary = s1 + " " + s2
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
|
finalSummary, _ = al.summarizeBatch(ctx, sumProvider, sumModel, agent.ID, validMessages, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if omitted && finalSummary != "" {
|
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(
|
func (al *AgentLoop) summarizeBatch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agent *AgentInstance,
|
provider providers.LLMProvider,
|
||||||
|
model string,
|
||||||
|
agentID string,
|
||||||
batch []providers.Message,
|
batch []providers.Message,
|
||||||
existingSummary string,
|
existingSummary string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
|
@ -1628,15 +1690,15 @@ func (al *AgentLoop) summarizeBatch(
|
||||||
}
|
}
|
||||||
prompt := sb.String()
|
prompt := sb.String()
|
||||||
|
|
||||||
response, err := agent.Provider.Chat(
|
response, err := provider.Chat(
|
||||||
ctx,
|
ctx,
|
||||||
[]providers.Message{{Role: "user", Content: prompt}},
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
nil,
|
nil,
|
||||||
agent.Model,
|
model,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"prompt_cache_key": agent.ID,
|
"prompt_cache_key": agentID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -26,15 +25,10 @@ type WebhookPayload struct {
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Message string `json:"message"`
|
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"`
|
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
|
// Tool/skill filtering
|
||||||
AllowedTools []string `json:"allowedTools,omitempty"` // Tool allowlist (empty = all)
|
AllowedTools []string `json:"allowedTools,omitempty"` // Tool allowlist (empty = all)
|
||||||
AllowedSkills []string `json:"allowedSkills,omitempty"` // Skill filter (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
|
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
|
// Return 200 immediately, process asynchronously
|
||||||
w.WriteHeader(http.StatusOK)
|
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
|
// Workspace override — agent loop will pick this up
|
||||||
if p.Workspace != "" {
|
if p.Workspace != "" {
|
||||||
metadata["workspace_override"] = 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)
|
// Config directory — agent loop reads config.json and copies bootstrap files
|
||||||
writeBootstrapFile(p.Workspace, "IDENTITY.md", p.AgentIdentity)
|
if p.ConfigDir != "" {
|
||||||
writeBootstrapFile(p.Workspace, "SOUL.md", p.AgentPersonality)
|
metadata["config_dir"] = p.ConfigDir
|
||||||
writeBootstrapFile(p.Workspace, "USER.md", p.UserContext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool/skill filtering — passed via metadata, picked up by agent loop
|
// 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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
@ -935,3 +936,160 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return true
|
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)
|
// 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
|
// Call agent with job's message
|
||||||
response, err := t.executor.ProcessDirectWithChannel(
|
response, err := t.executor.ProcessDirectWithChannel(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue