Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: unify agent loop limits via AgentLimits

Lyric a1c0d295 a937fb4a

+225 -139
+7 -3
agent/engine.go
··· 85 85 } 86 86 87 87 type Config struct { 88 - MaxSteps int 89 - MaxTokenBudget int 90 - ParseRetries int 88 + MaxSteps int 89 + MaxTokenBudget int 90 + ParseRetries int 91 + ToolRepeatLimit int 91 92 } 92 93 93 94 type Engine struct { ··· 117 118 } 118 119 if cfg.ParseRetries < 0 { 119 120 cfg.ParseRetries = 0 121 + } 122 + if cfg.ToolRepeatLimit <= 0 { 123 + cfg.ToolRepeatLimit = 3 120 124 } 121 125 if spec.Identity == "" { 122 126 spec = DefaultPromptSpec()
+29
agent/engine_hooks_test.go
··· 163 163 } 164 164 } 165 165 166 + func TestEngineConfig_DefaultToolRepeatLimit(t *testing.T) { 167 + client := newMockClient(finalResponse("ok")) 168 + e := New(client, baseRegistry(), Config{}, DefaultPromptSpec()) 169 + if e.config.ToolRepeatLimit != 3 { 170 + t.Fatalf("tool repeat limit = %d, want 3", e.config.ToolRepeatLimit) 171 + } 172 + } 173 + 174 + func TestToolRepeatLimit_Configurable(t *testing.T) { 175 + reg := baseRegistry() 176 + reg.Register(&mockTool{name: "search", result: "ok"}) 177 + client := newMockClient( 178 + toolCallResponse("search"), 179 + finalResponse("forced"), 180 + ) 181 + e := New(client, reg, Config{MaxSteps: 5, ToolRepeatLimit: 1}, DefaultPromptSpec()) 182 + 183 + f, _, err := e.Run(context.Background(), "test", RunOptions{}) 184 + if err != nil { 185 + t.Fatalf("unexpected error: %v", err) 186 + } 187 + if f == nil || f.Output != "forced" { 188 + t.Fatalf("unexpected final output: %#v", f) 189 + } 190 + if calls := client.allCalls(); len(calls) != 2 { 191 + t.Fatalf("chat calls = %d, want 2", len(calls)) 192 + } 193 + } 194 + 166 195 // ============================================================ 167 196 // Tests for Run() integration points 168 197 // ============================================================
+1 -3
agent/engine_loop.go
··· 36 36 lastToolRepeat int 37 37 } 38 38 39 - const toolRepeatLimit = 3 40 - 41 39 func newRunID() string { return fmt.Sprintf("%x", rand.Uint64()) } 42 40 43 41 func (e *Engine) runLoop(ctx context.Context, st *engineLoopState) (*Final, *Context, error) { ··· 383 381 st.lastToolSig = sig 384 382 st.lastToolRepeat = 1 385 383 } 386 - if st.lastToolRepeat >= toolRepeatLimit { 384 + if st.lastToolRepeat >= e.config.ToolRepeatLimit { 387 385 log.Warn("tool_repeat_limit_reached", "step", step, "tool", tc.Name, "repeat", st.lastToolRepeat) 388 386 st.messages = append(st.messages, llm.Message{ 389 387 Role: "user",
+43
agent/limits.go
··· 1 + package agent 2 + 3 + const ( 4 + DefaultMaxSteps = 15 5 + DefaultParseRetries = 2 6 + DefaultToolRepeatLimit = 3 7 + ) 8 + 9 + // Limits groups loop-control knobs so upper layers can pass agent limits 10 + // as a single value instead of repeating individual fields. 11 + type Limits struct { 12 + MaxSteps int 13 + ParseRetries int 14 + MaxTokenBudget int 15 + ToolRepeatLimit int 16 + } 17 + 18 + func (l Limits) ToConfig() Config { 19 + return Config{ 20 + MaxSteps: l.MaxSteps, 21 + ParseRetries: l.ParseRetries, 22 + MaxTokenBudget: l.MaxTokenBudget, 23 + ToolRepeatLimit: l.ToolRepeatLimit, 24 + } 25 + } 26 + 27 + // NormalizeForRuntime applies channel/runtime defaults that historically 28 + // treated <=0 values as unset for retries/repeat limits. 29 + func (l Limits) NormalizeForRuntime() Limits { 30 + if l.MaxSteps <= 0 { 31 + l.MaxSteps = DefaultMaxSteps 32 + } 33 + if l.ParseRetries <= 0 { 34 + l.ParseRetries = DefaultParseRetries 35 + } 36 + if l.ToolRepeatLimit <= 0 { 37 + l.ToolRepeatLimit = DefaultToolRepeatLimit 38 + } 39 + if l.MaxTokenBudget < 0 { 40 + l.MaxTokenBudget = 0 41 + } 42 + return l 43 + }
+2
assets/config/config.example.yaml
··· 333 333 parse_retries: 2 334 334 # - max_token_budget: stop the loop once cumulative tokens exceed this (0 disables). 335 335 max_token_budget: 0 336 + # - tool_repeat_limit: force final when the same successful tool call repeats this many times. 337 + tool_repeat_limit: 3 336 338 # Overall run timeout. 337 339 timeout: "10m" 338 340 # If true, prints extra debug info to stderr (tool steps, selected skills, etc).
+4 -3
cmd/mistermorph/daemoncmd/serve.go
··· 87 87 requireSkillProfiles := viper.GetBool("secrets.require_skill_profiles") 88 88 89 89 baseCfg := agent.Config{ 90 - MaxSteps: viper.GetInt("max_steps"), 91 - ParseRetries: viper.GetInt("parse_retries"), 92 - MaxTokenBudget: viper.GetInt("max_token_budget"), 90 + MaxSteps: viper.GetInt("max_steps"), 91 + ParseRetries: viper.GetInt("parse_retries"), 92 + MaxTokenBudget: viper.GetInt("max_token_budget"), 93 + ToolRepeatLimit: viper.GetInt("tool_repeat_limit"), 93 94 } 94 95 95 96 var sharedGuard *guard.Guard
+1
cmd/mistermorph/defaults.go
··· 20 20 viper.SetDefault("max_steps", 15) 21 21 viper.SetDefault("parse_retries", 2) 22 22 viper.SetDefault("max_token_budget", 0) 23 + viper.SetDefault("tool_repeat_limit", 3) 23 24 viper.SetDefault("timeout", 10*time.Minute) 24 25 viper.SetDefault("plan.max_steps", 6) 25 26 viper.SetDefault("tools.plan_create.enabled", true)
+5 -3
cmd/mistermorph/runcmd/run.go
··· 191 191 client, 192 192 reg, 193 193 agent.Config{ 194 - MaxSteps: configutil.FlagOrViperInt(cmd, "max-steps", "max_steps"), 195 - ParseRetries: configutil.FlagOrViperInt(cmd, "parse-retries", "parse_retries"), 196 - MaxTokenBudget: configutil.FlagOrViperInt(cmd, "max-token-budget", "max_token_budget"), 194 + MaxSteps: configutil.FlagOrViperInt(cmd, "max-steps", "max_steps"), 195 + ParseRetries: configutil.FlagOrViperInt(cmd, "parse-retries", "parse_retries"), 196 + MaxTokenBudget: configutil.FlagOrViperInt(cmd, "max-token-budget", "max_token_budget"), 197 + ToolRepeatLimit: configutil.FlagOrViperInt(cmd, "tool-repeat-limit", "tool_repeat_limit"), 197 198 }, 198 199 promptSpec, 199 200 opts..., ··· 242 243 cmd.Flags().Int("max-steps", 15, "Max tool-call steps.") 243 244 cmd.Flags().Int("parse-retries", 2, "Max JSON parse retries.") 244 245 cmd.Flags().Int("max-token-budget", 0, "Max cumulative token budget (0 disables).") 246 + cmd.Flags().Int("tool-repeat-limit", 3, "Force final when the same successful tool call repeats this many times.") 245 247 246 248 cmd.Flags().Duration("timeout", 10*time.Minute, "Overall timeout.") 247 249
+3
demo/embed-go/README.md
··· 17 17 GOCACHE=/tmp/gocache GOPATH=/tmp/gopath GOMODCACHE=/tmp/gomodcache \ 18 18 go run . \ 19 19 --mode task \ 20 + --max-steps 20 \ 21 + --tool-repeat-limit 5 \ 20 22 --task "List files in the current directory and summarize what this project is." 21 23 ``` 22 24 ··· 48 50 49 51 - This demo uses OpenAI-compatible provider, so network access is required to actually run. 50 52 - `--inspect-prompt` and `--inspect-request` are supported in all modes. 53 + - `--max-steps` and `--tool-repeat-limit` are supported in all modes. 51 54 - In `task` mode, the demo also registers example project tools (`list_dir`, `get_weather`) on top of selected built-ins. 52 55 - `telegram` and `slack` modes run until interrupted (`Ctrl+C`).
+10 -6
demo/embed-go/main.go
··· 116 116 117 117 func main() { 118 118 var ( 119 - mode = flag.String("mode", "task", "Run mode: task|telegram|slack.") 120 - task = flag.String("task", "List files and summarize the project.", "Task to run in --mode task.") 121 - model = flag.String("model", "gpt-5.2", "Model name.") 122 - apiKey = flag.String("api-key", os.Getenv("OPENAI_API_KEY"), "API key (defaults to OPENAI_API_KEY).") 123 - inspectPrompt = flag.Bool("inspect-prompt", false, "Dump prompts to ./dump.") 124 - inspectRequest = flag.Bool("inspect-request", false, "Dump request/response payloads to ./dump.") 119 + mode = flag.String("mode", "task", "Run mode: task|telegram|slack.") 120 + task = flag.String("task", "List files and summarize the project.", "Task to run in --mode task.") 121 + model = flag.String("model", "gpt-5.2", "Model name.") 122 + apiKey = flag.String("api-key", os.Getenv("OPENAI_API_KEY"), "API key (defaults to OPENAI_API_KEY).") 123 + maxSteps = flag.Int("max-steps", 15, "Agent max tool-call steps.") 124 + toolRepeatLimit = flag.Int("tool-repeat-limit", 3, "Force final when the same successful tool call repeats this many times.") 125 + inspectPrompt = flag.Bool("inspect-prompt", false, "Dump prompts to ./dump.") 126 + inspectRequest = flag.Bool("inspect-request", false, "Dump request/response payloads to ./dump.") 125 127 126 128 telegramBotToken = flag.String("telegram-bot-token", os.Getenv("TG_BOT_TOKEN"), "Telegram bot token (or TG_BOT_TOKEN).") 127 129 ··· 138 140 cfg.Set("llm.api_key", strings.TrimSpace(*apiKey)) 139 141 cfg.Set("llm.model", strings.TrimSpace(*model)) 140 142 cfg.Set("tools.todo_update.enabled", true) 143 + cfg.Set("max_steps", *maxSteps) 144 + cfg.Set("tool_repeat_limit", *toolRepeatLimit) 141 145 142 146 rt := integration.New(cfg) 143 147
+1
integration/defaults.go
··· 23 23 v.SetDefault("max_steps", 15) 24 24 v.SetDefault("parse_retries", 2) 25 25 v.SetDefault("max_token_budget", 0) 26 + v.SetDefault("tool_repeat_limit", 3) 26 27 v.SetDefault("timeout", 10*time.Minute) 27 28 v.SetDefault("plan.max_steps", 6) 28 29 v.SetDefault("tools.plan_create.enabled", true)
+1 -5
integration/runtime.go
··· 174 174 engine := agent.New( 175 175 client, 176 176 reg, 177 - agent.Config{ 178 - MaxSteps: snap.AgentMaxSteps, 179 - ParseRetries: snap.AgentParseRetries, 180 - MaxTokenBudget: snap.AgentMaxTokenBudget, 181 - }, 177 + snap.AgentLimits.ToConfig(), 182 178 promptSpec, 183 179 opts..., 184 180 )
+1 -3
integration/runtime_snapshot.go
··· 22 22 LLMAPIKey string 23 23 LLMModel string 24 24 LLMRequestTimeout time.Duration 25 - AgentMaxSteps int 26 - AgentParseRetries int 27 - AgentMaxTokenBudget int 25 + AgentLimits agent.Limits 28 26 SecretsRequireSkillProfiles bool 29 27 SkillsConfig skillsutil.SkillsConfig 30 28 Registry registrySnapshot
+16 -12
integration/runtime_snapshot_loader.go
··· 5 5 "strings" 6 6 "time" 7 7 8 + "github.com/quailyquaily/mistermorph/agent" 8 9 "github.com/quailyquaily/mistermorph/guard" 9 10 "github.com/quailyquaily/mistermorph/internal/channelopts" 10 11 "github.com/quailyquaily/mistermorph/internal/llmutil" ··· 59 60 provider := strings.TrimSpace(llmValues.Provider) 60 61 61 62 return runtimeSnapshot{ 62 - Logger: logger, 63 - LoggerInitErr: loggerErr, 64 - LogOptions: logOpts, 65 - LLMValues: llmValues, 66 - LLMProvider: provider, 67 - LLMEndpoint: llmutil.EndpointForProviderWithValues(provider, llmValues), 68 - LLMAPIKey: llmutil.APIKeyForProviderWithValues(provider, llmValues), 69 - LLMModel: llmutil.ModelForProviderWithValues(provider, llmValues), 70 - LLMRequestTimeout: v.GetDuration("llm.request_timeout"), 71 - AgentMaxSteps: v.GetInt("max_steps"), 72 - AgentParseRetries: v.GetInt("parse_retries"), 73 - AgentMaxTokenBudget: v.GetInt("max_token_budget"), 63 + Logger: logger, 64 + LoggerInitErr: loggerErr, 65 + LogOptions: logOpts, 66 + LLMValues: llmValues, 67 + LLMProvider: provider, 68 + LLMEndpoint: llmutil.EndpointForProviderWithValues(provider, llmValues), 69 + LLMAPIKey: llmutil.APIKeyForProviderWithValues(provider, llmValues), 70 + LLMModel: llmutil.ModelForProviderWithValues(provider, llmValues), 71 + LLMRequestTimeout: v.GetDuration("llm.request_timeout"), 72 + AgentLimits: agent.Limits{ 73 + MaxSteps: v.GetInt("max_steps"), 74 + ParseRetries: v.GetInt("parse_retries"), 75 + MaxTokenBudget: v.GetInt("max_token_budget"), 76 + ToolRepeatLimit: v.GetInt("tool_repeat_limit"), 77 + }, 74 78 SecretsRequireSkillProfiles: v.GetBool("secrets.require_skill_profiles"), 75 79 SkillsConfig: cloneSkillsConfig(skillsutil.SkillsConfigFromReader(v)), 76 80 Registry: registrySnapshot{
+28 -29
internal/channelopts/options.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/quailyquaily/mistermorph/agent" 9 10 slackruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/slack" 10 11 telegramruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/telegram" 11 12 "github.com/spf13/viper" ··· 36 37 ServerMaxQueue int 37 38 BusMaxInFlight int 38 39 RequestTimeout time.Duration 39 - AgentMaxSteps int 40 - AgentParseRetries int 41 - AgentMaxTokenBudget int 40 + AgentLimits agent.Limits 42 41 FileCacheMaxAge time.Duration 43 42 FileCacheMaxFiles int 44 43 FileCacheMaxTotalBytes int64 ··· 85 84 ServerMaxQueue: r.GetInt("server.max_queue"), 86 85 BusMaxInFlight: r.GetInt("bus.max_inflight"), 87 86 RequestTimeout: r.GetDuration("llm.request_timeout"), 88 - AgentMaxSteps: r.GetInt("max_steps"), 89 - AgentParseRetries: r.GetInt("parse_retries"), 90 - AgentMaxTokenBudget: r.GetInt("max_token_budget"), 91 - FileCacheMaxAge: r.GetDuration("file_cache.max_age"), 92 - FileCacheMaxFiles: r.GetInt("file_cache.max_files"), 93 - FileCacheMaxTotalBytes: r.GetInt64("file_cache.max_total_bytes"), 94 - HeartbeatEnabled: r.GetBool("heartbeat.enabled"), 95 - HeartbeatInterval: r.GetDuration("heartbeat.interval"), 96 - MemoryEnabled: r.GetBool("memory.enabled"), 97 - MemoryShortTermDays: r.GetInt("memory.short_term_days"), 98 - MemoryInjectionEnabled: r.GetBool("memory.injection.enabled"), 99 - MemoryInjectionMaxItems: r.GetInt("memory.injection.max_items"), 100 - SecretsRequireSkillProfiles: r.GetBool("secrets.require_skill_profiles"), 87 + AgentLimits: agent.Limits{ 88 + MaxSteps: r.GetInt("max_steps"), 89 + ParseRetries: r.GetInt("parse_retries"), 90 + MaxTokenBudget: r.GetInt("max_token_budget"), 91 + ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 92 + }, 93 + FileCacheMaxAge: r.GetDuration("file_cache.max_age"), 94 + FileCacheMaxFiles: r.GetInt("file_cache.max_files"), 95 + FileCacheMaxTotalBytes: r.GetInt64("file_cache.max_total_bytes"), 96 + HeartbeatEnabled: r.GetBool("heartbeat.enabled"), 97 + HeartbeatInterval: r.GetDuration("heartbeat.interval"), 98 + MemoryEnabled: r.GetBool("memory.enabled"), 99 + MemoryShortTermDays: r.GetInt("memory.short_term_days"), 100 + MemoryInjectionEnabled: r.GetBool("memory.injection.enabled"), 101 + MemoryInjectionMaxItems: r.GetInt("memory.injection.max_items"), 102 + SecretsRequireSkillProfiles: r.GetBool("secrets.require_skill_profiles"), 101 103 } 102 104 } 103 105 ··· 163 165 ServerMaxQueue: cfg.ServerMaxQueue, 164 166 BusMaxInFlight: cfg.BusMaxInFlight, 165 167 RequestTimeout: cfg.RequestTimeout, 166 - AgentMaxSteps: cfg.AgentMaxSteps, 167 - AgentParseRetries: cfg.AgentParseRetries, 168 - AgentMaxTokenBudget: cfg.AgentMaxTokenBudget, 168 + AgentLimits: cfg.AgentLimits, 169 169 FileCacheMaxAge: cfg.FileCacheMaxAge, 170 170 FileCacheMaxFiles: cfg.FileCacheMaxFiles, 171 171 FileCacheMaxTotalBytes: cfg.FileCacheMaxTotalBytes, ··· 227 227 BaseURL string 228 228 BusMaxInFlight int 229 229 RequestTimeout time.Duration 230 - AgentMaxSteps int 231 - AgentParseRetries int 232 - AgentMaxTokenBudget int 230 + AgentLimits agent.Limits 233 231 SecretsRequireSkillProfiles bool 234 232 } 235 233 ··· 268 266 BaseURL: strings.TrimSpace(r.GetString("slack.base_url")), 269 267 BusMaxInFlight: r.GetInt("bus.max_inflight"), 270 268 RequestTimeout: r.GetDuration("llm.request_timeout"), 271 - AgentMaxSteps: r.GetInt("max_steps"), 272 - AgentParseRetries: r.GetInt("parse_retries"), 273 - AgentMaxTokenBudget: r.GetInt("max_token_budget"), 274 - SecretsRequireSkillProfiles: r.GetBool("secrets.require_skill_profiles"), 269 + AgentLimits: agent.Limits{ 270 + MaxSteps: r.GetInt("max_steps"), 271 + ParseRetries: r.GetInt("parse_retries"), 272 + MaxTokenBudget: r.GetInt("max_token_budget"), 273 + ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 274 + }, 275 + SecretsRequireSkillProfiles: r.GetBool("secrets.require_skill_profiles"), 275 276 } 276 277 } 277 278 ··· 334 335 BaseURL: baseURL, 335 336 BusMaxInFlight: cfg.BusMaxInFlight, 336 337 RequestTimeout: cfg.RequestTimeout, 337 - AgentMaxSteps: cfg.AgentMaxSteps, 338 - AgentParseRetries: cfg.AgentParseRetries, 339 - AgentMaxTokenBudget: cfg.AgentMaxTokenBudget, 338 + AgentLimits: cfg.AgentLimits, 340 339 SecretsRequireSkillProfiles: cfg.SecretsRequireSkillProfiles, 341 340 Hooks: in.Hooks, 342 341 InspectPrompt: in.InspectPrompt,
+10
internal/channelopts/options_test.go
··· 3 3 import ( 4 4 "testing" 5 5 "time" 6 + 7 + "github.com/quailyquaily/mistermorph/agent" 6 8 ) 7 9 8 10 func TestParseTelegramAllowedChatIDs(t *testing.T) { ··· 32 34 GlobalTaskTimeout: 2 * time.Minute, 33 35 PollTimeout: 30 * time.Second, 34 36 MaxConcurrency: 3, 37 + AgentLimits: agent.Limits{ToolRepeatLimit: 9}, 35 38 DefaultGroupTriggerMode: "smart", 36 39 DefaultAddressingConfidenceThreshold: 0.6, 37 40 DefaultAddressingInterjectThreshold: 0.6, ··· 50 53 if len(opts.AllowedChatIDs) != 1 || opts.AllowedChatIDs[0] != 100 { 51 54 t.Fatalf("allowed chat ids = %#v, want [100]", opts.AllowedChatIDs) 52 55 } 56 + if opts.AgentLimits.ToolRepeatLimit != 9 { 57 + t.Fatalf("agent tool repeat limit = %d, want 9", opts.AgentLimits.ToolRepeatLimit) 58 + } 53 59 } 54 60 55 61 func TestBuildSlackRunOptionsTaskTimeoutFallback(t *testing.T) { ··· 58 64 TaskTimeout: 0, 59 65 GlobalTaskTimeout: 3 * time.Minute, 60 66 MaxConcurrency: 3, 67 + AgentLimits: agent.Limits{ToolRepeatLimit: 11}, 61 68 DefaultGroupTriggerMode: "smart", 62 69 DefaultAddressingConfidenceThreshold: 0.6, 63 70 DefaultAddressingInterjectThreshold: 0.6, ··· 70 77 ) 71 78 if opts.TaskTimeout != 3*time.Minute { 72 79 t.Fatalf("task timeout = %v, want 3m", opts.TaskTimeout) 80 + } 81 + if opts.AgentLimits.ToolRepeatLimit != 11 { 82 + t.Fatalf("agent tool repeat limit = %d, want 11", opts.AgentLimits.ToolRepeatLimit) 73 83 } 74 84 }
+2 -8
internal/channelruntime/slack/runtime.go
··· 41 41 BaseURL string 42 42 BusMaxInFlight int 43 43 RequestTimeout time.Duration 44 - AgentMaxSteps int 45 - AgentParseRetries int 46 - AgentMaxTokenBudget int 44 + AgentLimits agent.Limits 47 45 SecretsRequireSkillProfiles bool 48 46 Hooks Hooks 49 47 InspectPrompt bool ··· 199 197 registerPlanTool(d, reg, client, model) 200 198 toolsutil.BindTodoUpdateToolLLM(reg, client, model) 201 199 202 - cfg := agent.Config{ 203 - MaxSteps: opts.AgentMaxSteps, 204 - ParseRetries: opts.AgentParseRetries, 205 - MaxTokenBudget: opts.AgentMaxTokenBudget, 206 - } 200 + cfg := opts.AgentLimits.ToConfig() 207 201 taskRuntimeOpts := runtimeTaskOptions{ 208 202 SecretsRequireSkillProfiles: opts.SecretsRequireSkillProfiles, 209 203 }
+5 -12
internal/channelruntime/slack/runtime_options.go
··· 3 3 import ( 4 4 "strings" 5 5 "time" 6 + 7 + "github.com/quailyquaily/mistermorph/agent" 6 8 ) 7 9 8 10 type runtimeLoopOptions struct { ··· 22 24 BaseURL string 23 25 BusMaxInFlight int 24 26 RequestTimeout time.Duration 25 - AgentMaxSteps int 26 - AgentParseRetries int 27 - AgentMaxTokenBudget int 27 + AgentLimits agent.Limits 28 28 SecretsRequireSkillProfiles bool 29 29 InspectPrompt bool 30 30 InspectRequest bool ··· 48 48 Hooks: opts.Hooks, 49 49 BusMaxInFlight: opts.BusMaxInFlight, 50 50 RequestTimeout: opts.RequestTimeout, 51 - AgentMaxSteps: opts.AgentMaxSteps, 52 - AgentParseRetries: opts.AgentParseRetries, 53 - AgentMaxTokenBudget: opts.AgentMaxTokenBudget, 51 + AgentLimits: opts.AgentLimits, 54 52 SecretsRequireSkillProfiles: opts.SecretsRequireSkillProfiles, 55 53 InspectPrompt: opts.InspectPrompt, 56 54 InspectRequest: opts.InspectRequest, ··· 83 81 if opts.RequestTimeout <= 0 { 84 82 opts.RequestTimeout = 90 * time.Second 85 83 } 86 - if opts.AgentMaxSteps <= 0 { 87 - opts.AgentMaxSteps = 15 88 - } 89 - if opts.AgentParseRetries <= 0 { 90 - opts.AgentParseRetries = 2 91 - } 84 + opts.AgentLimits = opts.AgentLimits.NormalizeForRuntime() 92 85 if opts.GroupTriggerMode == "" { 93 86 opts.GroupTriggerMode = "smart" 94 87 }
+19 -11
internal/channelruntime/slack/runtime_options_test.go
··· 3 3 import ( 4 4 "testing" 5 5 "time" 6 + 7 + "github.com/quailyquaily/mistermorph/agent" 6 8 ) 7 9 8 10 func TestNormalizeSlackRunStringSlice(t *testing.T) { ··· 26 28 if got.RequestTimeout != 90*time.Second { 27 29 t.Fatalf("request timeout = %v, want 90s", got.RequestTimeout) 28 30 } 29 - if got.AgentMaxSteps != 15 { 30 - t.Fatalf("agent max steps = %d, want 15", got.AgentMaxSteps) 31 + if got.AgentLimits.MaxSteps != 15 { 32 + t.Fatalf("agent max steps = %d, want 15", got.AgentLimits.MaxSteps) 33 + } 34 + if got.AgentLimits.ParseRetries != 2 { 35 + t.Fatalf("agent parse retries = %d, want 2", got.AgentLimits.ParseRetries) 31 36 } 32 - if got.AgentParseRetries != 2 { 33 - t.Fatalf("agent parse retries = %d, want 2", got.AgentParseRetries) 37 + if got.AgentLimits.ToolRepeatLimit != 3 { 38 + t.Fatalf("agent tool repeat limit = %d, want 3", got.AgentLimits.ToolRepeatLimit) 34 39 } 35 40 if got.MaxConcurrency != 3 { 36 41 t.Fatalf("max concurrency = %d, want 3", got.MaxConcurrency) ··· 64 69 BaseURL: " https://example.com/api ", 65 70 BusMaxInFlight: 4096, 66 71 RequestTimeout: 30 * time.Second, 67 - AgentMaxSteps: 20, 68 - AgentParseRetries: 5, 69 - AgentMaxTokenBudget: 2048, 70 - SecretsRequireSkillProfiles: true, 71 - InspectPrompt: true, 72 - InspectRequest: true, 72 + AgentLimits: agent.Limits{ 73 + MaxSteps: 20, 74 + ParseRetries: 5, 75 + MaxTokenBudget: 2048, 76 + ToolRepeatLimit: 6, 77 + }, 78 + SecretsRequireSkillProfiles: true, 79 + InspectPrompt: true, 80 + InspectRequest: true, 73 81 }) 74 82 if got.BotToken != "xoxb" || got.AppToken != "xapp" { 75 83 t.Fatalf("token normalization mismatch: %#v", got) ··· 80 88 if len(got.AllowedChannelIDs) != 1 || got.AllowedChannelIDs[0] != "C1" { 81 89 t.Fatalf("allowed channel ids = %#v, want [C1]", got.AllowedChannelIDs) 82 90 } 83 - if got.BaseURL != "https://example.com/api" || got.BusMaxInFlight != 4096 || got.AgentParseRetries != 5 { 91 + if got.BaseURL != "https://example.com/api" || got.BusMaxInFlight != 4096 || got.AgentLimits.ParseRetries != 5 || got.AgentLimits.ToolRepeatLimit != 6 { 84 92 t.Fatalf("resolved options mismatch: %#v", got) 85 93 } 86 94 if !got.SecretsRequireSkillProfiles {
+3 -3
internal/channelruntime/telegram/run.go
··· 4 4 "context" 5 5 "strings" 6 6 "time" 7 + 8 + "github.com/quailyquaily/mistermorph/agent" 7 9 ) 8 10 9 11 type RunOptions struct { ··· 21 23 ServerMaxQueue int 22 24 BusMaxInFlight int 23 25 RequestTimeout time.Duration 24 - AgentMaxSteps int 25 - AgentParseRetries int 26 - AgentMaxTokenBudget int 26 + AgentLimits agent.Limits 27 27 FileCacheMaxAge time.Duration 28 28 FileCacheMaxFiles int 29 29 FileCacheMaxTotalBytes int64
+1 -6
internal/channelruntime/telegram/runtime.go
··· 12 12 "sync" 13 13 "time" 14 14 15 - "github.com/quailyquaily/mistermorph/agent" 16 15 "github.com/quailyquaily/mistermorph/contacts" 17 16 "github.com/quailyquaily/mistermorph/guard" 18 17 busruntime "github.com/quailyquaily/mistermorph/internal/bus" ··· 213 212 toolsutil.BindTodoUpdateToolLLM(reg, client, model) 214 213 logOpts := logOptionsFromDeps(d) 215 214 216 - cfg := agent.Config{ 217 - MaxSteps: opts.AgentMaxSteps, 218 - ParseRetries: opts.AgentParseRetries, 219 - MaxTokenBudget: opts.AgentMaxTokenBudget, 220 - } 215 + cfg := opts.AgentLimits.ToConfig() 221 216 taskRuntimeOpts := runtimeTaskOptions{ 222 217 MemoryEnabled: opts.MemoryEnabled, 223 218 MemoryShortTermDays: opts.MemoryShortTermDays,
+5 -12
internal/channelruntime/telegram/runtime_options.go
··· 3 3 import ( 4 4 "strings" 5 5 "time" 6 + 7 + "github.com/quailyquaily/mistermorph/agent" 6 8 ) 7 9 8 10 type runtimeLoopOptions struct { ··· 21 23 Hooks Hooks 22 24 BusMaxInFlight int 23 25 RequestTimeout time.Duration 24 - AgentMaxSteps int 25 - AgentParseRetries int 26 - AgentMaxTokenBudget int 26 + AgentLimits agent.Limits 27 27 FileCacheMaxAge time.Duration 28 28 FileCacheMaxFiles int 29 29 FileCacheMaxTotalBytes int64 ··· 55 55 Hooks: opts.Hooks, 56 56 BusMaxInFlight: opts.BusMaxInFlight, 57 57 RequestTimeout: opts.RequestTimeout, 58 - AgentMaxSteps: opts.AgentMaxSteps, 59 - AgentParseRetries: opts.AgentParseRetries, 60 - AgentMaxTokenBudget: opts.AgentMaxTokenBudget, 58 + AgentLimits: opts.AgentLimits, 61 59 FileCacheMaxAge: opts.FileCacheMaxAge, 62 60 FileCacheMaxFiles: opts.FileCacheMaxFiles, 63 61 FileCacheMaxTotalBytes: opts.FileCacheMaxTotalBytes, ··· 100 98 if opts.RequestTimeout <= 0 { 101 99 opts.RequestTimeout = 90 * time.Second 102 100 } 103 - if opts.AgentMaxSteps <= 0 { 104 - opts.AgentMaxSteps = 15 105 - } 106 - if opts.AgentParseRetries <= 0 { 107 - opts.AgentParseRetries = 2 108 - } 101 + opts.AgentLimits = opts.AgentLimits.NormalizeForRuntime() 109 102 if opts.FileCacheMaxAge <= 0 { 110 103 opts.FileCacheMaxAge = 7 * 24 * time.Hour 111 104 }
+28 -20
internal/channelruntime/telegram/runtime_options_test.go
··· 3 3 import ( 4 4 "testing" 5 5 "time" 6 + 7 + "github.com/quailyquaily/mistermorph/agent" 6 8 ) 7 9 8 10 func TestResolveRuntimeLoopOptionsFromRunOptions(t *testing.T) { ··· 19 21 ServerListen: "127.0.0.1:8080", 20 22 BusMaxInFlight: 2048, 21 23 RequestTimeout: 75 * time.Second, 22 - AgentMaxSteps: 20, 23 - AgentParseRetries: 4, 24 - AgentMaxTokenBudget: 1000, 25 - FileCacheMaxAge: 24 * time.Hour, 26 - FileCacheMaxFiles: 200, 27 - FileCacheMaxTotalBytes: int64(64 * 1024 * 1024), 28 - HeartbeatEnabled: true, 29 - HeartbeatInterval: 15 * time.Minute, 30 - MemoryEnabled: true, 31 - MemoryShortTermDays: 30, 32 - MemoryInjectionEnabled: true, 33 - MemoryInjectionMaxItems: 10, 34 - SecretsRequireSkillProfiles: true, 35 - InspectPrompt: true, 36 - InspectRequest: true, 24 + AgentLimits: agent.Limits{ 25 + MaxSteps: 20, 26 + ParseRetries: 4, 27 + MaxTokenBudget: 1000, 28 + ToolRepeatLimit: 6, 29 + }, 30 + FileCacheMaxAge: 24 * time.Hour, 31 + FileCacheMaxFiles: 200, 32 + FileCacheMaxTotalBytes: int64(64 * 1024 * 1024), 33 + HeartbeatEnabled: true, 34 + HeartbeatInterval: 15 * time.Minute, 35 + MemoryEnabled: true, 36 + MemoryShortTermDays: 30, 37 + MemoryInjectionEnabled: true, 38 + MemoryInjectionMaxItems: 10, 39 + SecretsRequireSkillProfiles: true, 40 + InspectPrompt: true, 41 + InspectRequest: true, 37 42 }) 38 43 if got.BotToken != "token" { 39 44 t.Fatalf("bot token = %q, want token", got.BotToken) ··· 41 46 if len(got.AllowedChatIDs) != 2 || got.AllowedChatIDs[0] != 1 || got.AllowedChatIDs[1] != 2 { 42 47 t.Fatalf("allowed chat ids = %#v, want [1 2]", got.AllowedChatIDs) 43 48 } 44 - if got.BusMaxInFlight != 2048 || got.AgentMaxSteps != 20 || got.FileCacheMaxFiles != 200 { 49 + if got.BusMaxInFlight != 2048 || got.AgentLimits.MaxSteps != 20 || got.AgentLimits.ToolRepeatLimit != 6 || got.FileCacheMaxFiles != 200 { 45 50 t.Fatalf("resolved options mismatch: %#v", got) 46 51 } 47 52 if !got.HeartbeatEnabled || !got.MemoryEnabled || !got.SecretsRequireSkillProfiles { ··· 66 71 if got.RequestTimeout != 90*time.Second { 67 72 t.Fatalf("request timeout = %v, want 90s", got.RequestTimeout) 68 73 } 69 - if got.AgentMaxSteps != 15 { 70 - t.Fatalf("agent max steps = %d, want 15", got.AgentMaxSteps) 74 + if got.AgentLimits.MaxSteps != 15 { 75 + t.Fatalf("agent max steps = %d, want 15", got.AgentLimits.MaxSteps) 76 + } 77 + if got.AgentLimits.ParseRetries != 2 { 78 + t.Fatalf("agent parse retries = %d, want 2", got.AgentLimits.ParseRetries) 71 79 } 72 - if got.AgentParseRetries != 2 { 73 - t.Fatalf("agent parse retries = %d, want 2", got.AgentParseRetries) 80 + if got.AgentLimits.ToolRepeatLimit != 3 { 81 + t.Fatalf("agent tool repeat limit = %d, want 3", got.AgentLimits.ToolRepeatLimit) 74 82 } 75 83 if got.FileCacheDir != "~/.cache/morph" { 76 84 t.Fatalf("file cache dir = %q, want ~/.cache/morph", got.FileCacheDir)