Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

refactor: unify config defaults and runtime snapshots

Lyric 2ef52f22 260ef5fa

+1048 -703
+23 -32
cmd/mistermorph/consolecmd/agent_settings.go
··· 197 197 configSource = "defaults" 198 198 configValid = false 199 199 } 200 - effectiveLLM := settingsFromCurrentRuntime() 200 + effectiveLLM := s.settingsFromCurrentRuntime() 201 201 doc := configbootstrap.NewEmptyDocument() 202 202 if configValid { 203 203 doc, err = loadYAMLDocument(configPath) ··· 236 236 writeError(w, http.StatusBadRequest, err.Error()) 237 237 return 238 238 } 239 - effectiveLLM := resolveAgentSettingsLLM(req.LLM) 239 + effectiveLLM := resolveAgentSettingsLLMFromReader(s.currentRuntimeConfigReader(), req.LLM) 240 240 if _, err := validateAgentConfigDocument(serialized, effectiveLLM); err != nil { 241 241 writeError(w, http.StatusBadRequest, err.Error()) 242 242 return ··· 255 255 writeError(w, http.StatusInternalServerError, err.Error()) 256 256 return 257 257 } 258 - viper.Set(llmSettingsKey, expanded.Get(llmSettingsKey)) 259 - viper.Set(multimodalSettingsKey, expanded.Get(multimodalSettingsKey)) 260 - viper.Set(toolsSettingsKey, expanded.Get(toolsSettingsKey)) 261 - if s != nil && s.localRuntime != nil { 262 - if err := s.localRuntime.ReloadAgentConfig(); err != nil { 263 - writeError(w, http.StatusInternalServerError, err.Error()) 264 - return 265 - } 266 - } 267 - if s != nil && s.managed != nil { 268 - if err := s.managed.Restart(); err != nil { 269 - writeError(w, http.StatusInternalServerError, err.Error()) 270 - return 271 - } 272 - } 273 - 274 258 next := readAgentSettingsFromReader(expanded) 275 259 doc, docErr := configbootstrap.LoadDocumentBytes(serialized) 276 260 if docErr != nil { 277 261 writeError(w, http.StatusInternalServerError, docErr.Error()) 278 262 return 279 263 } 280 - next, envManaged := buildAgentSettingsResponseView(next, doc, settingsFromCurrentRuntime().Provider) 264 + next, envManaged := buildAgentSettingsResponseView(next, doc, s.settingsFromCurrentRuntime().Provider) 281 265 writeJSON(w, http.StatusOK, map[string]any{ 282 266 "ok": true, 283 267 "llm": next.LLM, ··· 302 286 writeError(w, http.StatusBadRequest, "invalid json") 303 287 return 304 288 } 305 - current := settingsFromCurrentRuntime() 289 + current := s.settingsFromCurrentRuntime() 306 290 endpoint := strings.TrimSpace(req.Endpoint) 307 291 if endpoint == "" { 308 292 endpoint = strings.TrimSpace(current.Endpoint) ··· 337 321 return 338 322 } 339 323 340 - settings, err := resolveAgentSettingsTestLLM(req) 324 + settings, err := resolveAgentSettingsTestLLMFromReader(s.currentRuntimeConfigReader(), req) 341 325 if err != nil { 342 326 writeError(w, http.StatusBadRequest, err.Error()) 343 327 return ··· 568 552 return tmp, nil 569 553 } 570 554 571 - func settingsFromCurrentRuntime() llmSettingsPayload { 572 - return llmSettingsPayloadFromRuntimeValues(currentConsoleLLMRuntimeValues()) 555 + func (s *server) settingsFromCurrentRuntime() llmSettingsPayload { 556 + return settingsFromRuntimeReader(s.currentRuntimeConfigReader()) 573 557 } 574 558 575 - func resolveAgentSettingsLLM(overrides llmSettingsUpdatePayload) llmSettingsPayload { 576 - return applyLLMSettingsUpdate(settingsFromCurrentRuntime(), overrides) 559 + func settingsFromRuntimeReader(reader *viper.Viper) llmSettingsPayload { 560 + return llmSettingsPayloadFromRuntimeValues(currentConsoleLLMRuntimeValuesFromReader(reader)) 577 561 } 578 562 579 - func resolveAgentSettingsTestLLM(req agentSettingsTestRequest) (llmSettingsPayload, error) { 563 + func resolveAgentSettingsLLMFromReader(reader *viper.Viper, overrides llmSettingsUpdatePayload) llmSettingsPayload { 564 + return applyLLMSettingsUpdate(settingsFromRuntimeReader(reader), overrides) 565 + } 566 + 567 + func resolveAgentSettingsTestLLMFromReader(reader *viper.Viper, req agentSettingsTestRequest) (llmSettingsPayload, error) { 580 568 targetProfile := agentSettingsTestTargetProfile(req) 581 - snapshot := resolveAgentSettingsTestSnapshot(req, targetProfile) 569 + snapshot := resolveAgentSettingsTestSnapshotFromReader(reader, req, targetProfile) 582 570 if targetProfile == "" || strings.EqualFold(targetProfile, llmutil.RouteProfileDefault) { 583 571 return resolveAgentSettingsTestDefaultLLM(snapshot) 584 572 } 585 573 return resolveAgentSettingsTestProfileLLM(snapshot, targetProfile) 586 574 } 587 575 588 - func resolveAgentSettingsTestSnapshot(req agentSettingsTestRequest, targetProfile string) llmSettingsPayload { 576 + func resolveAgentSettingsTestSnapshotFromReader(reader *viper.Viper, req agentSettingsTestRequest, targetProfile string) llmSettingsPayload { 589 577 if targetProfile != "" && !strings.EqualFold(targetProfile, llmutil.RouteProfileDefault) { 590 - return resolveAgentSettingsLLM(llmSettingsPayloadAsProfileTestUpdate(req.LLM)) 578 + return resolveAgentSettingsLLMFromReader(reader, llmSettingsPayloadAsProfileTestUpdate(req.LLM)) 591 579 } 592 - return resolveAgentSettingsLLM(llmSettingsPayloadAsNonEmptyUpdate(req.LLM)) 580 + return resolveAgentSettingsLLMFromReader(reader, llmSettingsPayloadAsNonEmptyUpdate(req.LLM)) 593 581 } 594 582 595 583 func agentSettingsTestTargetProfile(req agentSettingsTestRequest) string { ··· 782 770 return llmProfileSettingsPayload{}, false 783 771 } 784 772 785 - func currentConsoleLLMRuntimeValues() llmutil.RuntimeValues { 786 - values := llmutil.RuntimeValuesFromViper() 773 + func currentConsoleLLMRuntimeValuesFromReader(reader *viper.Viper) llmutil.RuntimeValues { 774 + if reader == nil { 775 + reader = viper.GetViper() 776 + } 777 + values := llmutil.RuntimeValuesFromReader(reader) 787 778 788 779 if _, value, ok := firstManagedEnv("MISTER_MORPH_LLM_PROVIDER"); ok { 789 780 values.Provider = strings.TrimSpace(value)
+20 -29
cmd/mistermorph/consolecmd/agent_settings_test.go
··· 197 197 if !strings.Contains(string(raw), "- remote_download") { 198 198 t.Fatalf("config missing multimodal update: %s", string(raw)) 199 199 } 200 - if got := viper.GetString("llm.provider"); got != "anthropic" { 201 - t.Fatalf("viper llm.provider = %q, want anthropic", got) 202 - } 203 - if !viper.GetBool("tools.write_file.enabled") || viper.GetBool("tools.bash.enabled") { 204 - t.Fatalf("viper tools not updated: write_file=%v bash=%v", viper.GetBool("tools.write_file.enabled"), viper.GetBool("tools.bash.enabled")) 205 - } 206 - if !viper.GetBool("tools.spawn.enabled") { 207 - t.Fatalf("viper tools.spawn.enabled = false, want true") 208 - } 209 - if !viper.GetBool("tools.powershell.enabled") { 210 - t.Fatalf("viper tools.powershell.enabled = false, want true") 211 - } 212 - 213 200 var payload struct { 214 201 OK bool `json:"ok"` 215 202 LLM llmSettingsPayload `json:"llm"` ··· 399 386 } 400 387 if !strings.Contains(out, "write_file:\n enabled: true") || !strings.Contains(out, "bash:\n enabled: false") { 401 388 t.Fatalf("config should preserve tools block: %s", out) 402 - } 403 - if got := viper.GetStringSlice("multimodal.image.sources"); len(got) != 2 || got[0] != "slack" || got[1] != "line" { 404 - t.Fatalf("viper multimodal.image.sources = %#v, want [slack line]", got) 405 - } 406 - if !viper.GetBool("tools.write_file.enabled") || viper.GetBool("tools.bash.enabled") { 407 - t.Fatalf("viper tools should be preserved: write_file=%v bash=%v", viper.GetBool("tools.write_file.enabled"), viper.GetBool("tools.bash.enabled")) 408 389 } 409 390 } 410 391 ··· 733 714 if rec.Code != http.StatusOK { 734 715 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 735 716 } 736 - if got := viper.GetString("llm.routes.heartbeat"); got != "burst" { 737 - t.Fatalf("viper llm.routes.heartbeat = %q, want burst", got) 717 + raw, err := os.ReadFile(configPath) 718 + if err != nil { 719 + t.Fatalf("ReadFile() error = %v", err) 720 + } 721 + out := string(raw) 722 + if !strings.Contains(out, "heartbeat: burst") { 723 + t.Fatalf("config should preserve llm.routes.heartbeat: %s", out) 738 724 } 739 - if got := viper.GetStringSlice("llm.routes.main_loop.fallback_profiles"); len(got) != 1 || got[0] != "cheap" { 740 - t.Fatalf("viper llm.routes.main_loop.fallback_profiles = %#v", got) 725 + if !strings.Contains(out, "fallback_profiles:") || !strings.Contains(out, "- cheap") { 726 + t.Fatalf("config should preserve main_loop fallback profiles: %s", out) 741 727 } 742 - if got := viper.GetString("llm.profiles.cheap.model"); got != "gpt-4.1-mini" { 743 - t.Fatalf("viper llm.profiles.cheap.model = %q, want gpt-4.1-mini", got) 728 + if !strings.Contains(out, "profiles:") || !strings.Contains(out, "cheap:") || !strings.Contains(out, "model: gpt-4.1-mini") { 729 + t.Fatalf("config should write updated profile config: %s", out) 744 730 } 745 731 } 746 732 ··· 1050 1036 if rec.Code != http.StatusOK { 1051 1037 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) 1052 1038 } 1053 - if got := viper.GetString("llm.api_key"); got != "sk-anthropic-env" { 1054 - t.Fatalf("viper llm.api_key = %q, want expanded env value", got) 1039 + raw, err := os.ReadFile(configPath) 1040 + if err != nil { 1041 + t.Fatalf("ReadFile() error = %v", err) 1042 + } 1043 + out := string(raw) 1044 + if !strings.Contains(out, "api_key: ${ANTHROPIC_API_KEY}") { 1045 + t.Fatalf("config should keep main api_key placeholder: %s", out) 1055 1046 } 1056 - if got := viper.GetString("llm.profiles.cheap.api_key"); got != "sk-cheap-env" { 1057 - t.Fatalf("viper llm.profiles.cheap.api_key = %q, want expanded env value", got) 1047 + if !strings.Contains(out, "api_key: ${CHEAP_API_KEY}") { 1048 + t.Fatalf("config should keep profile api_key placeholder: %s", out) 1058 1049 } 1059 1050 var payload struct { 1060 1051 LLM llmSettingsPayload `json:"llm"`
-39
cmd/mistermorph/consolecmd/console_settings.go
··· 198 198 writeError(w, http.StatusInternalServerError, err.Error()) 199 199 return 200 200 } 201 - tmp, err := readExpandedConsoleSettingsConfig(configPath) 202 - if err != nil { 203 - writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid config yaml: %v", err)) 204 - return 205 - } 206 - 207 - viper.Set("console.managed_runtimes", tmp.GetStringSlice("console.managed_runtimes")) 208 - viper.Set("telegram.bot_token", strings.TrimSpace(tmp.GetString("telegram.bot_token"))) 209 - viper.Set("telegram.allowed_chat_ids", append([]string(nil), tmp.GetStringSlice("telegram.allowed_chat_ids")...)) 210 - viper.Set("telegram.group_trigger_mode", strings.TrimSpace(tmp.GetString("telegram.group_trigger_mode"))) 211 - viper.Set("slack.bot_token", strings.TrimSpace(tmp.GetString("slack.bot_token"))) 212 - viper.Set("slack.app_token", strings.TrimSpace(tmp.GetString("slack.app_token"))) 213 - viper.Set("slack.allowed_team_ids", append([]string(nil), tmp.GetStringSlice("slack.allowed_team_ids")...)) 214 - viper.Set("slack.allowed_channel_ids", append([]string(nil), tmp.GetStringSlice("slack.allowed_channel_ids")...)) 215 - viper.Set("slack.group_trigger_mode", strings.TrimSpace(tmp.GetString("slack.group_trigger_mode"))) 216 - viper.Set("guard.enabled", tmp.GetBool("guard.enabled")) 217 - viper.Set( 218 - "guard.network.url_fetch.allowed_url_prefixes", 219 - append([]string(nil), tmp.GetStringSlice("guard.network.url_fetch.allowed_url_prefixes")...), 220 - ) 221 - viper.Set("guard.network.url_fetch.deny_private_ips", tmp.GetBool("guard.network.url_fetch.deny_private_ips")) 222 - viper.Set("guard.network.url_fetch.follow_redirects", tmp.GetBool("guard.network.url_fetch.follow_redirects")) 223 - viper.Set("guard.network.url_fetch.allow_proxy", tmp.GetBool("guard.network.url_fetch.allow_proxy")) 224 - viper.Set("guard.redaction.enabled", tmp.GetBool("guard.redaction.enabled")) 225 - viper.Set("guard.approvals.enabled", tmp.GetBool("guard.approvals.enabled")) 226 - 227 - if s != nil && s.localRuntime != nil && req.Guard != nil { 228 - if err := s.localRuntime.ReloadAgentConfig(); err != nil { 229 - writeError(w, http.StatusInternalServerError, err.Error()) 230 - return 231 - } 232 - } 233 - 234 - if s != nil && s.managed != nil { 235 - if err := s.managed.UpdateKinds(tmp.GetStringSlice("console.managed_runtimes")); err != nil { 236 - writeError(w, http.StatusInternalServerError, err.Error()) 237 - return 238 - } 239 - } 240 201 241 202 doc, docErr := configbootstrap.LoadDocumentBytes(serialized) 242 203 if docErr != nil {
+3 -19
cmd/mistermorph/consolecmd/console_settings_test.go
··· 160 160 req := httptest.NewRequest(http.MethodPut, "/api/settings/console", body) 161 161 rec := httptest.NewRecorder() 162 162 163 - (&server{managed: newManagedRuntimeSupervisor(nil, serveConfig{})}).handleConsoleSettings(rec, req) 163 + (&server{managed: newManagedRuntimeSupervisor(nil, false, false)}).handleConsoleSettings(rec, req) 164 164 165 165 if rec.Code != http.StatusOK { 166 166 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) ··· 179 179 if !strings.Contains(serialized, "allowed_url_prefixes:") || !strings.Contains(serialized, "https://api.openai.com") { 180 180 t.Fatalf("config missing guard update: %s", serialized) 181 181 } 182 - if got := viper.GetStringSlice("console.managed_runtimes"); len(got) != 2 || got[0] != "slack" || got[1] != "telegram" { 183 - t.Fatalf("viper managed runtimes = %#v, want [slack telegram]", got) 184 - } 185 - if got := viper.GetString("telegram.bot_token"); got != "tg-token" { 186 - t.Fatalf("telegram.bot_token = %q, want tg-token", got) 187 - } 188 - if got := viper.GetString("slack.app_token"); got != "xapp-app" { 189 - t.Fatalf("slack.app_token = %q, want xapp-app", got) 190 - } 191 - if !viper.GetBool("guard.enabled") || !viper.GetBool("guard.redaction.enabled") || !viper.GetBool("guard.approvals.enabled") { 192 - t.Fatalf("guard booleans not updated from response") 193 - } 194 - if got := viper.GetStringSlice("guard.network.url_fetch.allowed_url_prefixes"); len(got) != 2 || got[0] != "https://api.openai.com" || got[1] != "https://example.com" { 195 - t.Fatalf("guard.allowed_url_prefixes = %#v", got) 196 - } 197 - 198 182 var payload struct { 199 183 OK bool `json:"ok"` 200 184 ManagedRuntimes []string `json:"managed_runtimes"` ··· 244 228 }`)) 245 229 rec := httptest.NewRecorder() 246 230 247 - (&server{managed: newManagedRuntimeSupervisor(nil, serveConfig{})}).handleConsoleSettings(rec, req) 231 + (&server{managed: newManagedRuntimeSupervisor(nil, false, false)}).handleConsoleSettings(rec, req) 248 232 249 233 if rec.Code != http.StatusOK { 250 234 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String()) ··· 288 272 }`)) 289 273 rec := httptest.NewRecorder() 290 274 291 - (&server{managed: newManagedRuntimeSupervisor(nil, serveConfig{})}).handleConsoleSettings(rec, req) 275 + (&server{managed: newManagedRuntimeSupervisor(nil, false, false)}).handleConsoleSettings(rec, req) 292 276 293 277 if rec.Code != http.StatusOK { 294 278 t.Fatalf("status = %d, want %d (%s)", rec.Code, http.StatusOK, rec.Body.String())
+245 -161
cmd/mistermorph/consolecmd/local_runtime.go
··· 19 19 "github.com/quailyquaily/mistermorph/guard" 20 20 "github.com/quailyquaily/mistermorph/internal/acpclient" 21 21 busruntime "github.com/quailyquaily/mistermorph/internal/bus" 22 + "github.com/quailyquaily/mistermorph/internal/channelopts" 22 23 runtimecore "github.com/quailyquaily/mistermorph/internal/channelruntime/core" 23 24 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 24 25 heartbeatloop "github.com/quailyquaily/mistermorph/internal/channelruntime/heartbeat" ··· 77 78 defaultProvider string 78 79 } 79 80 81 + type consoleLocalRuntimeConfigSnapshot struct { 82 + reader *viper.Viper 83 + commonDeps depsutil.CommonDependencies 84 + } 85 + 80 86 type consoleLocalRuntime struct { 81 - logger *slog.Logger 82 - inspectors *consoleInspectors 83 - store *daemonruntime.ConsoleFileStore 84 - bus *busruntime.Inproc 85 - runner *runtimecore.ConversationRunner[string, consoleLocalTaskJob] 86 - contactsSvc *contacts.Service 87 - bundleMu sync.RWMutex 88 - bundle *consoleLocalRuntimeBundle 89 - managedRuntimeMu sync.RWMutex 90 - managedRuntimeRunning map[string]bool 91 - commonDeps depsutil.CommonDependencies 92 - memoryEnabled bool 93 - defaultTimeout time.Duration 94 - memoryInjectionEnabled bool 95 - memoryInjectionMaxItems int 96 - memRuntime runtimecore.MemoryRuntime 97 - streamHub *consoleStreamHub 98 - heartbeatState *heartbeatutil.State 99 - heartbeatPokeRequests chan heartbeatloop.PokeRequest 100 - handler http.Handler 101 - authToken string 102 - cancelWorkers context.CancelFunc 103 - seq atomic.Uint64 87 + logger *slog.Logger 88 + inspectors *consoleInspectors 89 + store *daemonruntime.ConsoleFileStore 90 + bus *busruntime.Inproc 91 + runner *runtimecore.ConversationRunner[string, consoleLocalTaskJob] 92 + contactsSvc *contacts.Service 93 + bundleMu sync.RWMutex 94 + bundle *consoleLocalRuntimeBundle 95 + runtimeConfigMu sync.RWMutex 96 + runtimeConfig consoleLocalRuntimeConfigSnapshot 97 + managedRuntimeMu sync.RWMutex 98 + managedRuntimeRunning map[string]bool 99 + memRuntime runtimecore.MemoryRuntime 100 + workersCtx context.Context 101 + heartbeatMu sync.Mutex 102 + streamHub *consoleStreamHub 103 + heartbeatState *heartbeatutil.State 104 + heartbeatPokeRequests chan heartbeatloop.PokeRequest 105 + heartbeatCancel context.CancelFunc 106 + handler http.Handler 107 + authToken string 108 + cancelWorkers context.CancelFunc 109 + seq atomic.Uint64 104 110 } 105 111 106 - func newConsoleLocalRuntime(cfg serveConfig) (*consoleLocalRuntime, error) { 107 - logger, err := logutil.LoggerFromViper() 112 + func newConsoleLocalRuntime(cfg serveConfig, reader *viper.Viper) (*consoleLocalRuntime, error) { 113 + logger, err := logutil.LoggerFromConfig(logutil.LoggerConfigFromReader(reader)) 108 114 if err != nil { 109 115 return nil, err 110 116 } 111 117 slog.SetDefault(logger) 112 - logOpts := logutil.LogOptionsFromViper() 113 118 inspectors, err := newConsoleInspectors(cfg.inspectPrompt, cfg.inspectRequest, "console", "console", "20060102_150405") 114 119 if err != nil { 115 120 return nil, err 116 121 } 117 122 out := &consoleLocalRuntime{ 118 - logger: logger, 119 - inspectors: inspectors, 123 + logger: logger, 124 + inspectors: inspectors, 125 + runtimeConfig: buildConsoleLocalRuntimeConfigSnapshot(logger, inspectors, reader), 120 126 } 121 - var baseRegistry *tools.Registry 122 - var sharedGuard *guard.Guard 123 - var mcpHost *mcphost.Host 124 - 125 - commonDeps := depsutil.CommonDependencies{ 126 - Logger: func() (*slog.Logger, error) { 127 - return logger, nil 128 - }, 129 - LogOptions: func() agent.LogOptions { 130 - return logOpts 131 - }, 132 - ResolveLLMRoute: func(purpose string) (llmutil.ResolvedRoute, error) { 133 - values := llmutil.RuntimeValuesFromViper() 134 - if strings.TrimSpace(purpose) == llmutil.RoutePurposeMainLoop { 135 - return llmselect.ResolveMainRoute(values, llmselect.ProcessStore().Get()) 136 - } 137 - return llmutil.ResolveRoute(values, purpose) 138 - }, 139 - CreateLLMClient: func(route llmutil.ResolvedRoute) (llm.Client, error) { 140 - return llmutil.BuildRouteClient( 141 - route, 142 - nil, 143 - llmutil.ClientFromConfigWithValues, 144 - func(client llm.Client, cfg llmconfig.ClientConfig, profile string) llm.Client { 145 - wrappedRoute := route 146 - wrappedRoute.Profile = strings.TrimSpace(profile) 147 - wrappedRoute.ClientConfig = cfg 148 - wrappedRoute.Fallbacks = nil 149 - wrapped := llmstats.WrapRuntimeClient(client, cfg.Provider, cfg.Endpoint, cfg.Model, logger) 150 - return inspectors.Wrap(wrapped, wrappedRoute) 151 - }, 152 - logger, 153 - ) 154 - }, 155 - RuntimeToolsConfig: toolsutil.LoadRuntimeToolsRegisterConfigFromViper(), 156 - Registry: func() *tools.Registry { 157 - return baseRegistry 158 - }, 159 - ACPAgents: acpclient.AgentsFromViper, 160 - Guard: func(_ *slog.Logger) *guard.Guard { 161 - return sharedGuard 162 - }, 163 - PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 164 - cfg := skillsutil.SkillsConfigFromViper() 165 - if len(stickySkills) > 0 { 166 - cfg.Requested = append(cfg.Requested, stickySkills...) 167 - } 168 - return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 169 - }, 170 - } 171 - 172 - baseRegistry, mcpHost = buildConsoleBaseRegistry(context.Background(), logger) 173 - sharedGuard = buildConsoleGuardFromViper(logger) 174 - taskRuntimeOpts := taskruntime.BootstrapOptions{ 175 - AgentConfig: consoleAgentConfigFromViper(), 176 - EngineToolsConfig: &agent.EngineToolsConfig{ 177 - SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled, 178 - ACPSpawnEnabled: consoleEngineToolsConfigFromViper().ACPSpawnEnabled, 179 - }, 180 - } 181 - execRuntime, err := taskruntime.Bootstrap(commonDeps, taskRuntimeOpts) 127 + snapshot := out.currentRuntimeConfig() 128 + bundle, err := buildConsoleLocalRuntimeBundle(logger, inspectors, snapshot) 182 129 if err != nil { 183 130 _ = inspectors.Close() 184 131 return nil, err 185 132 } 186 - if warning := consoleLLMCredentialsWarning(execRuntime.BootstrapMainRoute); warning != "" { 187 - logger.Warn("console_llm_credentials_missing", 188 - "provider", execRuntime.BootstrapMainProvider, 189 - "hint", warning, 190 - ) 191 - } 192 - 193 - defaultTimeout := viper.GetDuration("timeout") 194 - if defaultTimeout <= 0 { 195 - defaultTimeout = 10 * time.Minute 196 - } 197 - 133 + commonDeps := snapshot.commonDeps 198 134 workersCtx, cancelWorkers := context.WithCancel(context.Background()) 199 - memoryEnabled := viper.GetBool("memory.enabled") 200 135 memRuntime, err := runtimecore.NewMemoryRuntime(commonDeps, runtimecore.MemoryRuntimeOptions{ 201 - Enabled: memoryEnabled, 202 - ShortTermDays: viper.GetInt("memory.short_term_days"), 136 + Enabled: snapshot.reader.GetBool("memory.enabled"), 137 + ShortTermDays: snapshot.reader.GetInt("memory.short_term_days"), 203 138 Logger: logger, 204 139 }) 205 140 if err != nil { ··· 211 146 memRuntime.ProjectionWorker.Start(workersCtx) 212 147 } 213 148 214 - authToken, err := consoleLocalRuntimeAuthToken() 149 + authToken, err := consoleLocalRuntimeAuthTokenFromReader(snapshot.reader) 215 150 if err != nil { 216 151 _ = inspectors.Close() 217 152 cancelWorkers() ··· 220 155 } 221 156 store, err := daemonruntime.NewConsoleFileStore(daemonruntime.ConsoleFileStoreOptions{ 222 157 RootDir: statepaths.TaskTargetDir("console"), 223 - HeartbeatTopicID: strings.TrimSpace(viper.GetString("tasks.targets.console.heartbeat_topic_id")), 224 - Persist: consoleTaskPersistenceEnabled(), 158 + HeartbeatTopicID: strings.TrimSpace(snapshot.reader.GetString("tasks.targets.console.heartbeat_topic_id")), 159 + Persist: consoleTaskPersistenceEnabledFromReader(snapshot.reader), 225 160 }) 226 161 if err != nil { 227 162 _ = inspectors.Close() ··· 229 164 memRuntime.Cleanup() 230 165 return nil, err 231 166 } 232 - maxInFlight := viper.GetInt("bus.max_inflight") 167 + maxInFlight := snapshot.reader.GetInt("bus.max_inflight") 233 168 if maxInFlight <= 0 { 234 169 maxInFlight = 1024 235 170 } ··· 247 182 out.store = store 248 183 out.bus = inprocBus 249 184 out.streamHub = newConsoleStreamHub() 250 - out.bundle = &consoleLocalRuntimeBundle{ 251 - taskRuntime: execRuntime, 252 - mcpHost: mcpHost, 253 - defaultModel: execRuntime.BootstrapMainModel, 254 - defaultProvider: execRuntime.BootstrapMainProvider, 255 - } 185 + out.bundle = bundle 256 186 out.managedRuntimeRunning = map[string]bool{} 257 - out.commonDeps = commonDeps 258 - out.memoryEnabled = memoryEnabled 259 - out.defaultTimeout = defaultTimeout 260 - out.memoryInjectionEnabled = viper.GetBool("memory.injection.enabled") 261 - out.memoryInjectionMaxItems = viper.GetInt("memory.injection.max_items") 262 187 out.memRuntime = memRuntime 188 + out.workersCtx = workersCtx 263 189 out.contactsSvc = contacts.NewService(contacts.NewFileStore(statepaths.ContactsDir())) 264 190 out.authToken = authToken 265 191 out.cancelWorkers = cancelWorkers ··· 278 204 memRuntime.Cleanup() 279 205 return nil, err 280 206 } 281 - out.startHeartbeatLoop(workersCtx) 207 + out.reloadHeartbeatLoop() 282 208 out.handler = daemonruntime.NewHandler(out.routesOptions(strings.TrimSpace(authToken))) 283 209 return out, nil 284 210 } 285 211 286 - func consoleLocalRuntimeAuthToken() (string, error) { 287 - if token := strings.TrimSpace(viper.GetString("server.auth_token")); token != "" { 288 - return token, nil 212 + func buildConsoleLocalRuntimeConfigSnapshot(logger *slog.Logger, inspectors *consoleInspectors, reader *viper.Viper) consoleLocalRuntimeConfigSnapshot { 213 + if logger == nil { 214 + logger = slog.Default() 289 215 } 216 + if reader == nil { 217 + reader = viper.New() 218 + } 219 + logOpts := logutil.LogOptionsFromConfig(logutil.LogOptionsConfigFromReader(reader)) 220 + return consoleLocalRuntimeConfigSnapshot{ 221 + reader: reader, 222 + commonDeps: depsutil.CommonDependencies{ 223 + Logger: func() (*slog.Logger, error) { 224 + return logger, nil 225 + }, 226 + LogOptions: func() agent.LogOptions { 227 + return logOpts 228 + }, 229 + ResolveLLMRoute: func(purpose string) (llmutil.ResolvedRoute, error) { 230 + values := llmutil.RuntimeValuesFromReader(reader) 231 + if strings.TrimSpace(purpose) == llmutil.RoutePurposeMainLoop { 232 + return llmselect.ResolveMainRoute(values, llmselect.ProcessStore().Get()) 233 + } 234 + return llmutil.ResolveRoute(values, purpose) 235 + }, 236 + CreateLLMClient: func(route llmutil.ResolvedRoute) (llm.Client, error) { 237 + return llmutil.BuildRouteClient( 238 + route, 239 + nil, 240 + llmutil.ClientFromConfigWithValues, 241 + func(client llm.Client, cfg llmconfig.ClientConfig, profile string) llm.Client { 242 + wrappedRoute := route 243 + wrappedRoute.Profile = strings.TrimSpace(profile) 244 + wrappedRoute.ClientConfig = cfg 245 + wrappedRoute.Fallbacks = nil 246 + wrapped := llmstats.WrapRuntimeClient(client, cfg.Provider, cfg.Endpoint, cfg.Model, logger) 247 + if inspectors != nil { 248 + return inspectors.Wrap(wrapped, wrappedRoute) 249 + } 250 + return wrapped 251 + }, 252 + logger, 253 + ) 254 + }, 255 + RuntimeToolsConfig: toolsutil.LoadRuntimeToolsRegisterConfigFromReader(reader), 256 + ACPAgents: func() []acpclient.AgentConfig { 257 + return acpclient.AgentsFromReader(reader) 258 + }, 259 + PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 260 + cfg := skillsutil.SkillsConfigFromReader(reader) 261 + if len(stickySkills) > 0 { 262 + cfg.Requested = append(cfg.Requested, stickySkills...) 263 + } 264 + return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 265 + }, 266 + }, 267 + } 268 + } 269 + 270 + func consoleDefaultTimeoutFromReader(r interface { 271 + GetDuration(string) time.Duration 272 + }) time.Duration { 273 + if r == nil { 274 + return 10 * time.Minute 275 + } 276 + timeout := r.GetDuration("timeout") 277 + if timeout <= 0 { 278 + return 10 * time.Minute 279 + } 280 + return timeout 281 + } 282 + 283 + func consoleLocalRuntimeAuthTokenFromReader(r interface { 284 + GetString(string) string 285 + }) (string, error) { 286 + if r != nil { 287 + if token := strings.TrimSpace(r.GetString("server.auth_token")); token != "" { 288 + return token, nil 289 + } 290 + } 291 + return consoleLocalRuntimeAuthToken() 292 + } 293 + 294 + func consoleLocalRuntimeAuthToken() (string, error) { 290 295 raw := make([]byte, 24) 291 296 if _, err := rand.Read(raw); err != nil { 292 297 return "", fmt.Errorf("generate console local auth token: %w", err) ··· 294 299 return base64.RawURLEncoding.EncodeToString(raw), nil 295 300 } 296 301 302 + func (r *consoleLocalRuntime) currentRuntimeConfig() consoleLocalRuntimeConfigSnapshot { 303 + if r == nil { 304 + return consoleLocalRuntimeConfigSnapshot{} 305 + } 306 + r.runtimeConfigMu.RLock() 307 + defer r.runtimeConfigMu.RUnlock() 308 + return r.runtimeConfig 309 + } 310 + 311 + func (r *consoleLocalRuntime) currentConfigReader() *viper.Viper { 312 + reader := r.currentRuntimeConfig().reader 313 + if reader == nil { 314 + return viper.New() 315 + } 316 + return reader 317 + } 318 + 319 + func (r *consoleLocalRuntime) setRuntimeConfig(snapshot consoleLocalRuntimeConfigSnapshot) { 320 + if r == nil { 321 + return 322 + } 323 + r.runtimeConfigMu.Lock() 324 + defer r.runtimeConfigMu.Unlock() 325 + r.runtimeConfig = snapshot 326 + } 327 + 328 + func (r *consoleLocalRuntime) commonDependencies() depsutil.CommonDependencies { 329 + return r.currentRuntimeConfig().commonDeps 330 + } 331 + 297 332 func (r *consoleLocalRuntime) currentBundle() *consoleLocalRuntimeBundle { 298 333 if r == nil { 299 334 return nil ··· 305 340 306 341 func (r *consoleLocalRuntime) defaultLLMConfig() (string, string) { 307 342 if r != nil { 308 - route, err := depsutil.ResolveLLMRouteFromCommon(r.commonDeps, llmutil.RoutePurposeMainLoop) 343 + route, err := depsutil.ResolveLLMRouteFromCommon(r.commonDependencies(), llmutil.RoutePurposeMainLoop) 309 344 if err == nil { 310 345 return strings.TrimSpace(route.ClientConfig.Provider), strings.TrimSpace(route.ClientConfig.Model) 311 346 } ··· 317 352 return strings.TrimSpace(bundle.defaultProvider), strings.TrimSpace(bundle.defaultModel) 318 353 } 319 354 320 - func (r *consoleLocalRuntime) ReloadAgentConfig() error { 321 - if r == nil { 322 - return fmt.Errorf("console runtime is not initialized") 323 - } 324 - baseRegistry, mcpHost := buildConsoleBaseRegistry(context.Background(), r.logger) 325 - sharedGuard := buildConsoleGuardFromViper(r.logger) 326 - deps := r.commonDeps 355 + func buildConsoleLocalRuntimeBundle( 356 + logger *slog.Logger, 357 + inspectors *consoleInspectors, 358 + snapshot consoleLocalRuntimeConfigSnapshot, 359 + ) (*consoleLocalRuntimeBundle, error) { 360 + baseRegistry, mcpHost := buildConsoleBaseRegistryFromReader(context.Background(), logger, snapshot.reader) 361 + sharedGuard := buildConsoleGuardFromReader(logger, snapshot.reader) 362 + deps := snapshot.commonDeps 327 363 deps.Registry = func() *tools.Registry { return baseRegistry } 328 364 deps.Guard = func(_ *slog.Logger) *guard.Guard { return sharedGuard } 329 - deps.RuntimeToolsConfig = toolsutil.LoadRuntimeToolsRegisterConfigFromViper() 330 365 rt, err := taskruntime.Bootstrap(deps, taskruntime.BootstrapOptions{ 331 - AgentConfig: consoleAgentConfigFromViper(), 366 + AgentConfig: consoleAgentConfigFromReader(snapshot.reader), 332 367 EngineToolsConfig: &agent.EngineToolsConfig{ 333 - SpawnEnabled: consoleEngineToolsConfigFromViper().SpawnEnabled, 334 - ACPSpawnEnabled: consoleEngineToolsConfigFromViper().ACPSpawnEnabled, 368 + SpawnEnabled: consoleEngineToolsConfigFromReader(snapshot.reader).SpawnEnabled, 369 + ACPSpawnEnabled: consoleEngineToolsConfigFromReader(snapshot.reader).ACPSpawnEnabled, 335 370 }, 336 371 }) 337 372 if err != nil { 338 373 if mcpHost != nil { 339 374 _ = mcpHost.Close() 340 375 } 341 - return err 376 + return nil, err 342 377 } 343 378 if warning := consoleLLMCredentialsWarning(rt.BootstrapMainRoute); warning != "" { 344 - r.logger.Warn("console_llm_credentials_missing", 379 + logger.Warn("console_llm_credentials_missing", 345 380 "provider", rt.BootstrapMainProvider, 346 381 "hint", warning, 347 382 ) 348 383 } 349 - nextBundle := &consoleLocalRuntimeBundle{ 384 + return &consoleLocalRuntimeBundle{ 350 385 taskRuntime: rt, 351 386 mcpHost: mcpHost, 352 387 defaultModel: rt.BootstrapMainModel, 353 388 defaultProvider: rt.BootstrapMainProvider, 389 + }, nil 390 + } 391 + 392 + func (r *consoleLocalRuntime) ReloadAgentConfigFromReader(reader *viper.Viper) error { 393 + if r == nil { 394 + return fmt.Errorf("console runtime is not initialized") 395 + } 396 + snapshot := buildConsoleLocalRuntimeConfigSnapshot(r.logger, r.inspectors, reader) 397 + nextBundle, err := buildConsoleLocalRuntimeBundle(r.logger, r.inspectors, snapshot) 398 + if err != nil { 399 + return err 354 400 } 355 401 r.bundleMu.Lock() 356 402 prevBundle := r.bundle 357 403 r.bundle = nextBundle 358 404 r.bundleMu.Unlock() 405 + r.setRuntimeConfig(snapshot) 406 + r.reloadHeartbeatLoop() 359 407 if prevBundle != nil && prevBundle.mcpHost != nil { 360 408 _ = prevBundle.mcpHost.Close() 361 409 } ··· 369 417 if r.bus != nil { 370 418 _ = r.bus.Close() 371 419 } 420 + r.heartbeatMu.Lock() 421 + if r.heartbeatCancel != nil { 422 + r.heartbeatCancel() 423 + r.heartbeatCancel = nil 424 + } 425 + r.heartbeatPokeRequests = nil 426 + r.heartbeatMu.Unlock() 372 427 if r.cancelWorkers != nil { 373 428 r.cancelWorkers() 374 429 } ··· 453 508 HealthEnabled: true, 454 509 Overview: func(ctx context.Context) (map[string]any, error) { 455 510 provider, model := r.defaultLLMConfig() 511 + reader := r.currentConfigReader() 456 512 return map[string]any{ 457 513 "llm": map[string]any{ 458 514 "provider": provider, ··· 460 516 }, 461 517 "channel": map[string]any{ 462 518 "configured": true, 463 - "telegram_configured": strings.TrimSpace(viper.GetString("telegram.bot_token")) != "", 464 - "slack_configured": strings.TrimSpace(viper.GetString("slack.bot_token")) != "" && 465 - strings.TrimSpace(viper.GetString("slack.app_token")) != "", 519 + "telegram_configured": strings.TrimSpace(reader.GetString("telegram.bot_token")) != "", 520 + "slack_configured": strings.TrimSpace(reader.GetString("slack.bot_token")) != "" && 521 + strings.TrimSpace(reader.GetString("slack.app_token")) != "", 466 522 "running": "console", 467 523 "telegram_running": r.isManagedRuntimeRunning("telegram"), 468 524 "slack_running": r.isManagedRuntimeRunning("slack"), ··· 505 561 } 506 562 507 563 func (r *consoleLocalRuntime) submitTask(ctx context.Context, req daemonruntime.SubmitTaskRequest) (daemonruntime.SubmitTaskResponse, error) { 508 - timeout := r.defaultTimeout 564 + timeout := consoleDefaultTimeoutFromReader(r.currentConfigReader()) 509 565 if strings.TrimSpace(req.Timeout) != "" { 510 566 d, err := time.ParseDuration(strings.TrimSpace(req.Timeout)) 511 567 if err != nil || d <= 0 { ··· 535 591 } 536 592 537 593 func (r *consoleLocalRuntime) handleConsoleModelCommand(task string) (string, bool) { 538 - output, handled, err := llmselect.ExecuteCommandText(llmutil.RuntimeValuesFromViper(), llmselect.ProcessStore(), task) 594 + output, handled, err := llmselect.ExecuteCommandText( 595 + llmutil.RuntimeValuesFromReader(r.currentConfigReader()), 596 + llmselect.ProcessStore(), 597 + task, 598 + ) 539 599 if !handled { 540 600 return "", false 541 601 } ··· 687 747 Source: "console", 688 748 SubjectID: memSubjectID, 689 749 } 690 - if r.memoryEnabled && r.memRuntime.Orchestrator != nil && memSubjectID != "" { 691 - memoryHooks.InjectionEnabled = r.memoryInjectionEnabled 692 - memoryHooks.InjectionMaxItems = r.memoryInjectionMaxItems 750 + reader := r.currentConfigReader() 751 + if reader.GetBool("memory.enabled") && r.memRuntime.Orchestrator != nil && memSubjectID != "" { 752 + memoryHooks.InjectionEnabled = reader.GetBool("memory.injection.enabled") 753 + memoryHooks.InjectionMaxItems = reader.GetInt("memory.injection.max_items") 693 754 memoryHooks.PrepareInjection = func(maxItems int) (string, error) { 694 755 return r.memRuntime.Orchestrator.PrepareInjection(memoryruntime.PrepareInjectionRequest{ 695 756 SubjectID: memSubjectID, ··· 822 883 if r == nil { 823 884 return "", fmt.Errorf("console runtime is not initialized") 824 885 } 825 - route, err := depsutil.ResolveLLMRouteFromCommon(r.commonDeps, llmutil.RoutePurposeMainLoop) 886 + commonDeps := r.commonDependencies() 887 + route, err := depsutil.ResolveLLMRouteFromCommon(commonDeps, llmutil.RoutePurposeMainLoop) 826 888 if err != nil { 827 889 return "", err 828 890 } 829 - client, err := depsutil.CreateClientFromCommon(r.commonDeps, route) 891 + client, err := depsutil.CreateClientFromCommon(commonDeps, route) 830 892 if err != nil { 831 893 return "", err 832 894 } ··· 887 949 return out 888 950 } 889 951 890 - func consoleTaskPersistenceEnabled() bool { 891 - for _, target := range viper.GetStringSlice("tasks.persistence_targets") { 952 + func consoleTaskPersistenceEnabledFromReader(r interface { 953 + GetStringSlice(string) []string 954 + }) bool { 955 + if r == nil { 956 + return false 957 + } 958 + for _, target := range r.GetStringSlice("tasks.persistence_targets") { 892 959 if strings.EqualFold(strings.TrimSpace(target), "console") { 893 960 return true 894 961 } ··· 1048 1115 job, _, err := r.acceptTask( 1049 1116 task, 1050 1117 model, 1051 - r.defaultTimeout, 1118 + consoleDefaultTimeoutFromReader(r.currentConfigReader()), 1052 1119 r.store.HeartbeatTopicID(), 1053 1120 daemonruntime.ConsoleHeartbeatTopicTitle, 1054 1121 trigger, ··· 1067 1134 return "" 1068 1135 } 1069 1136 1070 - func (r *consoleLocalRuntime) startHeartbeatLoop(ctx context.Context) { 1137 + func (r *consoleLocalRuntime) reloadHeartbeatLoop() { 1071 1138 if r == nil { 1072 1139 return 1073 1140 } 1074 - hbEnabled := viper.GetBool("heartbeat.enabled") 1075 - hbInterval := viper.GetDuration("heartbeat.interval") 1076 - if !hbEnabled || hbInterval <= 0 { 1141 + r.heartbeatMu.Lock() 1142 + if r.heartbeatCancel != nil { 1143 + r.heartbeatCancel() 1144 + r.heartbeatCancel = nil 1145 + } 1146 + r.heartbeatPokeRequests = nil 1147 + if r.workersCtx == nil { 1148 + r.heartbeatState = nil 1149 + r.heartbeatMu.Unlock() 1150 + return 1151 + } 1152 + hbCfg := channelopts.HeartbeatConfigFromReader(r.currentConfigReader()) 1153 + if !hbCfg.Enabled || hbCfg.Interval <= 0 { 1154 + r.heartbeatMu.Unlock() 1077 1155 return 1078 1156 } 1079 - hbState := &heartbeatutil.State{} 1080 - r.heartbeatState = hbState 1157 + if r.heartbeatState == nil { 1158 + r.heartbeatState = &heartbeatutil.State{} 1159 + } 1160 + hbState := r.heartbeatState 1081 1161 hbChecklist := statepaths.HeartbeatChecklistPath() 1082 - r.heartbeatPokeRequests = make(chan heartbeatloop.PokeRequest) 1162 + hbCtx, cancel := context.WithCancel(r.workersCtx) 1163 + pokeRequests := make(chan heartbeatloop.PokeRequest) 1164 + r.heartbeatCancel = cancel 1165 + r.heartbeatPokeRequests = pokeRequests 1166 + r.heartbeatMu.Unlock() 1083 1167 1084 1168 runHeartbeatTick := func(wakeSignal daemonruntime.PokeInput) heartbeatutil.TickResult { 1085 1169 if !r.canSubmit() { ··· 1114 1198 } 1115 1199 1116 1200 go func() { 1117 - heartbeatloop.RunScheduler(ctx, heartbeatloop.SchedulerOptions{ 1201 + heartbeatloop.RunScheduler(hbCtx, heartbeatloop.SchedulerOptions{ 1118 1202 InitialDelay: 15 * time.Second, 1119 - Interval: hbInterval, 1120 - PokeRequests: r.heartbeatPokeRequests, 1203 + Interval: hbCfg.Interval, 1204 + PokeRequests: pokeRequests, 1121 1205 }, runHeartbeatTick) 1122 1206 }() 1123 1207 }
+1 -1
cmd/mistermorph/consolecmd/local_runtime_bus.go
··· 192 192 TopicID: stored.TopicID, 193 193 Task: stored.Task, 194 194 Model: stored.Model, 195 - Timeout: parseConsoleTaskTimeout(stored.Timeout, r.defaultTimeout), 195 + Timeout: parseConsoleTaskTimeout(stored.Timeout, consoleDefaultTimeoutFromReader(r.currentConfigReader())), 196 196 CreatedAt: stored.CreatedAt, 197 197 Trigger: trigger, 198 198 AutoRenameTopic: autoRename,
+54 -38
cmd/mistermorph/consolecmd/managed_runtime.go
··· 35 35 type managedRuntimeSupervisor struct { 36 36 mu sync.Mutex 37 37 kinds []string 38 + configReader *viper.Viper 38 39 inspectPrompt bool 39 40 inspectRequest bool 40 41 localRuntime *consoleLocalRuntime ··· 84 85 return out, nil 85 86 } 86 87 87 - func newManagedRuntimeSupervisor(localRuntime *consoleLocalRuntime, cfg serveConfig) *managedRuntimeSupervisor { 88 + func newManagedRuntimeSupervisor(localRuntime *consoleLocalRuntime, inspectPrompt bool, inspectRequest bool) *managedRuntimeSupervisor { 88 89 return &managedRuntimeSupervisor{ 89 - kinds: append([]string(nil), cfg.managedKinds...), 90 - inspectPrompt: cfg.inspectPrompt, 91 - inspectRequest: cfg.inspectRequest, 90 + inspectPrompt: inspectPrompt, 91 + inspectRequest: inspectRequest, 92 92 localRuntime: localRuntime, 93 93 } 94 94 } ··· 107 107 return s.startLocked() 108 108 } 109 109 110 - func (s *managedRuntimeSupervisor) Restart() error { 110 + func (s *managedRuntimeSupervisor) ReloadConfig(reader *viper.Viper) error { 111 111 if s == nil { 112 112 return nil 113 113 } 114 - s.mu.Lock() 115 - defer s.mu.Unlock() 116 - if s.parentCtx == nil { 117 - return nil 118 - } 119 - s.stopLocked() 120 - return s.startLocked() 121 - } 122 - 123 - func (s *managedRuntimeSupervisor) UpdateKinds(kinds []string) error { 124 - if s == nil { 125 - return nil 114 + if reader == nil { 115 + reader = viper.GetViper() 126 116 } 127 - normalized, err := normalizeManagedRuntimeKinds(kinds) 117 + kinds, err := managedRuntimeKindsFromReader(reader) 128 118 if err != nil { 129 119 return err 130 120 } 131 121 s.mu.Lock() 132 122 defer s.mu.Unlock() 133 123 s.stopLocked() 134 - s.kinds = append([]string(nil), normalized...) 124 + s.configReader = reader 125 + s.kinds = append([]string(nil), kinds...) 135 126 if s.parentCtx == nil { 136 127 return nil 137 128 } ··· 175 166 } 176 167 cancel() 177 168 s.cancel = nil 178 - for _, item := range s.kinds { 179 - s.localRuntime.SetManagedRuntimeRunning(item, false) 169 + if s.localRuntime != nil { 170 + for _, item := range s.kinds { 171 + s.localRuntime.SetManagedRuntimeRunning(item, false) 172 + } 180 173 } 181 174 return err 182 175 } 183 - s.localRuntime.SetManagedRuntimeRunning(kind, true) 176 + if s.localRuntime != nil { 177 + s.localRuntime.SetManagedRuntimeRunning(kind, true) 178 + } 184 179 go s.runManagedRuntime(runCtx, generation, kind, run, cleanup) 185 180 } 186 181 return nil ··· 199 194 } 200 195 201 196 func (s *managedRuntimeSupervisor) buildRuntimeLocked(kind string) (func(context.Context) error, func(), error) { 197 + reader := s.currentConfigReaderLocked() 198 + runtimeValues := llmutil.RuntimeValuesFromReader(reader) 202 199 switch kind { 203 200 case managedRuntimeTelegram: 204 - botToken := strings.TrimSpace(viper.GetString("telegram.bot_token")) 201 + botToken := strings.TrimSpace(reader.GetString("telegram.bot_token")) 205 202 if botToken == "" { 206 203 return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing telegram.bot_token (set via --telegram-bot-token or MISTER_MORPH_TELEGRAM_BOT_TOKEN)")} 207 204 } 208 - deps, cleanup := buildManagedRuntimeDeps(s.logger()) 209 - cfg := channelopts.TelegramConfigFromViper() 205 + deps, cleanup := buildManagedRuntimeDepsFromReader(s.logger(), reader) 206 + cfg := channelopts.TelegramConfigFromReader(reader) 210 207 runOpts, err := channelopts.BuildTelegramRunOptions(cfg, channelopts.TelegramInput{ 211 208 BotToken: botToken, 212 209 InspectPrompt: s.inspectPrompt, ··· 227 224 runtimeDeps := telegramruntime.Dependencies{ 228 225 CommonDependencies: deps, 229 226 HandleModelCommand: func(text string) (string, bool, error) { 230 - return llmselect.ExecuteCommandText(llmutil.RuntimeValuesFromViper(), llmselect.ProcessStore(), text) 227 + return llmselect.ExecuteCommandText(runtimeValues, llmselect.ProcessStore(), text) 231 228 }, 232 229 } 233 230 return func(ctx context.Context) error { 234 231 return telegramruntime.Run(ctx, runtimeDeps, runOpts) 235 232 }, cleanup, nil 236 233 case managedRuntimeSlack: 237 - botToken := strings.TrimSpace(viper.GetString("slack.bot_token")) 234 + botToken := strings.TrimSpace(reader.GetString("slack.bot_token")) 238 235 if botToken == "" { 239 236 return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing slack.bot_token (set via --slack-bot-token or MISTER_MORPH_SLACK_BOT_TOKEN)")} 240 237 } 241 - appToken := strings.TrimSpace(viper.GetString("slack.app_token")) 238 + appToken := strings.TrimSpace(reader.GetString("slack.app_token")) 242 239 if appToken == "" { 243 240 return nil, nil, managedRuntimeConfigError{err: fmt.Errorf("missing slack.app_token (set via --slack-app-token or MISTER_MORPH_SLACK_APP_TOKEN)")} 244 241 } 245 - deps, cleanup := buildManagedRuntimeDeps(s.logger()) 246 - cfg := channelopts.SlackConfigFromViper() 242 + deps, cleanup := buildManagedRuntimeDepsFromReader(s.logger(), reader) 243 + cfg := channelopts.SlackConfigFromReader(reader) 247 244 runOpts := channelopts.BuildSlackRunOptions(cfg, channelopts.SlackInput{ 248 245 BotToken: botToken, 249 246 AppToken: appToken, ··· 262 259 runtimeDeps := slackruntime.Dependencies{ 263 260 CommonDependencies: deps, 264 261 HandleModelCommand: func(text string) (string, bool, error) { 265 - return llmselect.ExecuteCommandText(llmutil.RuntimeValuesFromViper(), llmselect.ProcessStore(), text) 262 + return llmselect.ExecuteCommandText(runtimeValues, llmselect.ProcessStore(), text) 266 263 }, 267 264 } 268 265 return func(ctx context.Context) error { ··· 278 275 return s.localRuntime.logger 279 276 } 280 277 return slog.Default() 278 + } 279 + 280 + func managedRuntimeKindsFromReader(r interface { 281 + GetStringSlice(string) []string 282 + }) ([]string, error) { 283 + if r == nil { 284 + return nil, nil 285 + } 286 + return normalizeManagedRuntimeKinds(r.GetStringSlice("console.managed_runtimes")) 281 287 } 282 288 283 289 func isManagedRuntimeConfigError(err error) bool { ··· 324 330 return s.generation == generation 325 331 } 326 332 327 - func buildManagedRuntimeDeps(logger *slog.Logger) (depsutil.CommonDependencies, func()) { 333 + func (s *managedRuntimeSupervisor) currentConfigReaderLocked() *viper.Viper { 334 + if s != nil && s.configReader != nil { 335 + return s.configReader 336 + } 337 + return viper.GetViper() 338 + } 339 + 340 + func buildManagedRuntimeDepsFromReader(logger *slog.Logger, reader *viper.Viper) (depsutil.CommonDependencies, func()) { 328 341 if logger == nil { 329 342 logger = slog.Default() 330 343 } 331 - logOpts := logutil.LogOptionsFromViper() 332 - baseRegistry, mcpHost := buildConsoleBaseRegistry(context.Background(), logger) 333 - sharedGuard := buildConsoleGuardFromViper(logger) 344 + if reader == nil { 345 + reader = viper.GetViper() 346 + } 347 + logOpts := logutil.LogOptionsFromConfig(logutil.LogOptionsConfigFromReader(reader)) 348 + baseRegistry, mcpHost := buildConsoleBaseRegistryFromReader(context.Background(), logger, reader) 349 + sharedGuard := buildConsoleGuardFromReader(logger, reader) 334 350 deps := depsutil.CommonDependencies{ 335 351 Logger: func() (*slog.Logger, error) { 336 352 return logger, nil ··· 339 355 return logOpts 340 356 }, 341 357 ResolveLLMRoute: func(purpose string) (llmutil.ResolvedRoute, error) { 342 - values := llmutil.RuntimeValuesFromViper() 358 + values := llmutil.RuntimeValuesFromReader(reader) 343 359 if strings.TrimSpace(purpose) == llmutil.RoutePurposeMainLoop { 344 360 return llmselect.ResolveMainRoute(values, llmselect.ProcessStore().Get()) 345 361 } ··· 356 372 logger, 357 373 ) 358 374 }, 359 - RuntimeToolsConfig: toolsutil.LoadRuntimeToolsRegisterConfigFromViper(), 375 + RuntimeToolsConfig: toolsutil.LoadRuntimeToolsRegisterConfigFromReader(reader), 360 376 Registry: func() *tools.Registry { 361 377 return baseRegistry 362 378 }, ··· 364 380 return sharedGuard 365 381 }, 366 382 PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 367 - cfg := skillsutil.SkillsConfigFromViper() 383 + cfg := skillsutil.SkillsConfigFromReader(reader) 368 384 if len(stickySkills) > 0 { 369 385 cfg.Requested = append(cfg.Requested, stickySkills...) 370 386 }
+15 -1
cmd/mistermorph/consolecmd/managed_runtime_test.go
··· 10 10 func TestManagedRuntimeSupervisorStartSkipsConfigError(t *testing.T) { 11 11 viper.Reset() 12 12 t.Cleanup(viper.Reset) 13 + viper.Set("console.managed_runtimes", []string{"telegram"}) 13 14 14 - supervisor := newManagedRuntimeSupervisor(nil, serveConfig{managedKinds: []string{"telegram"}}) 15 + supervisor := newManagedRuntimeSupervisor(nil, false, false) 16 + if err := supervisor.ReloadConfig(viper.GetViper()); err != nil { 17 + t.Fatalf("ReloadConfig() error = %v, want nil", err) 18 + } 15 19 if err := supervisor.Start(context.Background(), nil); err != nil { 16 20 t.Fatalf("Start() error = %v, want nil", err) 17 21 } 18 22 supervisor.Close() 19 23 } 24 + 25 + func TestManagedRuntimeKindsFromReaderRejectsUnsupportedValue(t *testing.T) { 26 + v := viper.New() 27 + v.Set("console.managed_runtimes", []string{"telegram", "line"}) 28 + 29 + _, err := managedRuntimeKindsFromReader(v) 30 + if err == nil || err.Error() == "" { 31 + t.Fatalf("managedRuntimeKindsFromReader() error = %v, want unsupported value", err) 32 + } 33 + }
+162
cmd/mistermorph/consolecmd/runtime_config.go
··· 1 + package consolecmd 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + "time" 8 + 9 + "github.com/quailyquaily/mistermorph/internal/configdefaults" 10 + "github.com/quailyquaily/mistermorph/internal/configutil" 11 + "github.com/quailyquaily/mistermorph/internal/pathutil" 12 + "github.com/spf13/cobra" 13 + "github.com/spf13/viper" 14 + ) 15 + 16 + const ( 17 + consoleRuntimeEnvPrefix = "MISTER_MORPH" 18 + consoleConfigPollInterval = time.Second 19 + ) 20 + 21 + type consoleRuntimeOverrides map[string]any 22 + 23 + type consoleConfigFingerprint struct { 24 + exists bool 25 + size int64 26 + modTimeNS int64 27 + } 28 + 29 + func captureConsoleRuntimeOverrides(cmd *cobra.Command) consoleRuntimeOverrides { 30 + if cmd == nil { 31 + return nil 32 + } 33 + flags := cmd.InheritedFlags() 34 + if flags == nil { 35 + return nil 36 + } 37 + 38 + out := consoleRuntimeOverrides{} 39 + setString := func(flagName, key string) { 40 + if !flags.Changed(flagName) { 41 + return 42 + } 43 + value, err := flags.GetString(flagName) 44 + if err != nil { 45 + return 46 + } 47 + out[key] = value 48 + } 49 + setBool := func(flagName, key string) { 50 + if !flags.Changed(flagName) { 51 + return 52 + } 53 + value, err := flags.GetBool(flagName) 54 + if err != nil { 55 + return 56 + } 57 + out[key] = value 58 + } 59 + setInt := func(flagName, key string) { 60 + if !flags.Changed(flagName) { 61 + return 62 + } 63 + value, err := flags.GetInt(flagName) 64 + if err != nil { 65 + return 66 + } 67 + out[key] = value 68 + } 69 + setStringArray := func(flagName, key string) { 70 + if !flags.Changed(flagName) { 71 + return 72 + } 73 + value, err := flags.GetStringArray(flagName) 74 + if err != nil { 75 + return 76 + } 77 + out[key] = append([]string(nil), value...) 78 + } 79 + 80 + setString("log-level", "logging.level") 81 + setString("log-format", "logging.format") 82 + setBool("log-add-source", "logging.add_source") 83 + setBool("log-include-thoughts", "logging.include_thoughts") 84 + setBool("log-include-tool-params", "logging.include_tool_params") 85 + setBool("log-include-skill-contents", "logging.include_skill_contents") 86 + setInt("log-max-thought-chars", "logging.max_thought_chars") 87 + setInt("log-max-json-bytes", "logging.max_json_bytes") 88 + setInt("log-max-string-value-chars", "logging.max_string_value_chars") 89 + setInt("log-max-skill-content-chars", "logging.max_skill_content_chars") 90 + setStringArray("log-redact-key", "logging.redact_keys") 91 + 92 + if len(out) == 0 { 93 + return nil 94 + } 95 + return out 96 + } 97 + 98 + func (o consoleRuntimeOverrides) apply(v *viper.Viper) { 99 + if v == nil { 100 + return 101 + } 102 + for key, value := range o { 103 + key = strings.TrimSpace(key) 104 + if key == "" { 105 + continue 106 + } 107 + v.Set(key, value) 108 + } 109 + } 110 + 111 + func loadConsoleRuntimeConfig(configPath string, overrides consoleRuntimeOverrides) (*viper.Viper, error) { 112 + v := viper.New() 113 + configdefaults.Apply(v) 114 + v.SetEnvPrefix(consoleRuntimeEnvPrefix) 115 + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) 116 + v.AutomaticEnv() 117 + overrides.apply(v) 118 + 119 + configPath = pathutil.ExpandHomePath(strings.TrimSpace(configPath)) 120 + if configPath != "" { 121 + if err := configutil.ReadExpandedConfig(v, configPath, nil); err != nil && !os.IsNotExist(err) { 122 + return nil, err 123 + } 124 + expandConfiguredDirKeyOnReader(v, "file_state_dir") 125 + expandConfiguredDirKeyOnReader(v, "file_cache_dir") 126 + } 127 + return v, nil 128 + } 129 + 130 + func expandConfiguredDirKeyOnReader(v *viper.Viper, key string) { 131 + if v == nil { 132 + return 133 + } 134 + key = strings.TrimSpace(key) 135 + if key == "" { 136 + return 137 + } 138 + raw := strings.TrimSpace(v.GetString(key)) 139 + if raw == "" { 140 + return 141 + } 142 + v.Set(key, pathutil.ExpandHomePath(raw)) 143 + } 144 + 145 + func fingerprintConfigPath(path string) (consoleConfigFingerprint, error) { 146 + path = pathutil.ExpandHomePath(strings.TrimSpace(path)) 147 + if path == "" { 148 + return consoleConfigFingerprint{}, nil 149 + } 150 + info, err := os.Stat(path) 151 + if err != nil { 152 + if os.IsNotExist(err) { 153 + return consoleConfigFingerprint{}, nil 154 + } 155 + return consoleConfigFingerprint{}, fmt.Errorf("stat config: %w", err) 156 + } 157 + return consoleConfigFingerprint{ 158 + exists: true, 159 + size: info.Size(), 160 + modTimeNS: info.ModTime().UnixNano(), 161 + }, nil 162 + }
+102 -59
cmd/mistermorph/consolecmd/runtime_support.go
··· 63 63 } 64 64 65 65 func loadConsoleRegistryConfigFromViper() consoleRegistryConfig { 66 + return loadConsoleRegistryConfigFromReader(viper.GetViper()) 67 + } 68 + 69 + func loadConsoleRegistryConfigFromReader(r *viper.Viper) consoleRegistryConfig { 70 + if r == nil { 71 + return consoleRegistryConfig{} 72 + } 66 73 authProfiles := map[string]secrets.AuthProfile{} 67 - _ = viper.UnmarshalKey("auth_profiles", &authProfiles) 74 + _ = r.UnmarshalKey("auth_profiles", &authProfiles) 68 75 for id, profile := range authProfiles { 69 76 profile.ID = id 70 77 authProfiles[id] = profile 71 78 } 72 79 73 - fileStateDir := strings.TrimSpace(viper.GetString("file_state_dir")) 80 + fileStateDir := strings.TrimSpace(r.GetString("file_state_dir")) 74 81 return consoleRegistryConfig{ 75 - UserAgent: strings.TrimSpace(viper.GetString("user_agent")), 76 - SecretsAllowProfiles: append([]string(nil), viper.GetStringSlice("secrets.allow_profiles")...), 82 + UserAgent: strings.TrimSpace(r.GetString("user_agent")), 83 + SecretsAllowProfiles: append([]string(nil), r.GetStringSlice("secrets.allow_profiles")...), 77 84 AuthProfiles: copyConsoleAuthProfilesMap(authProfiles), 78 - FileCacheDir: strings.TrimSpace(viper.GetString("file_cache_dir")), 85 + FileCacheDir: strings.TrimSpace(r.GetString("file_cache_dir")), 79 86 FileStateDir: fileStateDir, 80 - ToolsReadFileMaxBytes: int64(viper.GetInt("tools.read_file.max_bytes")), 81 - ToolsReadFileDenyPaths: append([]string(nil), viper.GetStringSlice("tools.read_file.deny_paths")...), 82 - ToolsWriteFileEnabled: viper.GetBool("tools.write_file.enabled"), 83 - ToolsWriteFileMaxBytes: viper.GetInt("tools.write_file.max_bytes"), 84 - ToolsBashEnabled: viper.GetBool("tools.bash.enabled"), 85 - ToolsBashTimeout: viper.GetDuration("tools.bash.timeout"), 86 - ToolsBashMaxOutputBytes: viper.GetInt("tools.bash.max_output_bytes"), 87 - ToolsBashDenyPaths: append([]string(nil), viper.GetStringSlice("tools.bash.deny_paths")...), 88 - ToolsBashInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.bash.injected_env_vars")...), 89 - ToolsPowerShellEnabled: viper.GetBool("tools.powershell.enabled"), 90 - ToolsPowerShellTimeout: viper.GetDuration("tools.powershell.timeout"), 91 - ToolsPowerShellMaxOutputBytes: viper.GetInt("tools.powershell.max_output_bytes"), 92 - ToolsPowerShellDenyPaths: append([]string(nil), viper.GetStringSlice("tools.powershell.deny_paths")...), 93 - ToolsPowerShellInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.powershell.injected_env_vars")...), 94 - ToolsURLFetchEnabled: viper.GetBool("tools.url_fetch.enabled"), 95 - ToolsURLFetchTimeout: viper.GetDuration("tools.url_fetch.timeout"), 96 - ToolsURLFetchMaxBytes: viper.GetInt64("tools.url_fetch.max_bytes"), 97 - ToolsURLFetchMaxBytesDownload: viper.GetInt64("tools.url_fetch.max_bytes_download"), 98 - ToolsWebSearchEnabled: viper.GetBool("tools.web_search.enabled"), 99 - ToolsWebSearchTimeout: viper.GetDuration("tools.web_search.timeout"), 100 - ToolsWebSearchMaxResults: viper.GetInt("tools.web_search.max_results"), 101 - ToolsWebSearchBaseURL: strings.TrimSpace(viper.GetString("tools.web_search.base_url")), 102 - ToolsContactsSendEnabled: viper.GetBool("tools.contacts_send.enabled"), 103 - ContactsDir: pathutil.ResolveStateChildDir(fileStateDir, strings.TrimSpace(viper.GetString("contacts.dir_name")), "contacts"), 104 - TelegramBotToken: strings.TrimSpace(viper.GetString("telegram.bot_token")), 87 + ToolsReadFileMaxBytes: int64(r.GetInt("tools.read_file.max_bytes")), 88 + ToolsReadFileDenyPaths: append([]string(nil), r.GetStringSlice("tools.read_file.deny_paths")...), 89 + ToolsWriteFileEnabled: r.GetBool("tools.write_file.enabled"), 90 + ToolsWriteFileMaxBytes: r.GetInt("tools.write_file.max_bytes"), 91 + ToolsBashEnabled: r.GetBool("tools.bash.enabled"), 92 + ToolsBashTimeout: r.GetDuration("tools.bash.timeout"), 93 + ToolsBashMaxOutputBytes: r.GetInt("tools.bash.max_output_bytes"), 94 + ToolsBashDenyPaths: append([]string(nil), r.GetStringSlice("tools.bash.deny_paths")...), 95 + ToolsBashInjectedEnvVars: append([]string(nil), r.GetStringSlice("tools.bash.injected_env_vars")...), 96 + ToolsPowerShellEnabled: r.GetBool("tools.powershell.enabled"), 97 + ToolsPowerShellTimeout: r.GetDuration("tools.powershell.timeout"), 98 + ToolsPowerShellMaxOutputBytes: r.GetInt("tools.powershell.max_output_bytes"), 99 + ToolsPowerShellDenyPaths: append([]string(nil), r.GetStringSlice("tools.powershell.deny_paths")...), 100 + ToolsPowerShellInjectedEnvVars: append([]string(nil), r.GetStringSlice("tools.powershell.injected_env_vars")...), 101 + ToolsURLFetchEnabled: r.GetBool("tools.url_fetch.enabled"), 102 + ToolsURLFetchTimeout: r.GetDuration("tools.url_fetch.timeout"), 103 + ToolsURLFetchMaxBytes: r.GetInt64("tools.url_fetch.max_bytes"), 104 + ToolsURLFetchMaxBytesDownload: r.GetInt64("tools.url_fetch.max_bytes_download"), 105 + ToolsWebSearchEnabled: r.GetBool("tools.web_search.enabled"), 106 + ToolsWebSearchTimeout: r.GetDuration("tools.web_search.timeout"), 107 + ToolsWebSearchMaxResults: r.GetInt("tools.web_search.max_results"), 108 + ToolsWebSearchBaseURL: strings.TrimSpace(r.GetString("tools.web_search.base_url")), 109 + ToolsContactsSendEnabled: r.GetBool("tools.contacts_send.enabled"), 110 + ContactsDir: pathutil.ResolveStateChildDir(fileStateDir, strings.TrimSpace(r.GetString("contacts.dir_name")), "contacts"), 111 + TelegramBotToken: strings.TrimSpace(r.GetString("telegram.bot_token")), 105 112 TelegramBaseURL: "https://api.telegram.org", 106 - SlackBotToken: strings.TrimSpace(viper.GetString("slack.bot_token")), 107 - SlackBaseURL: strings.TrimSpace(viper.GetString("slack.base_url")), 108 - LineChannelAccessToken: strings.TrimSpace(viper.GetString("line.channel_access_token")), 109 - LineBaseURL: strings.TrimSpace(viper.GetString("line.base_url")), 110 - LarkAppID: strings.TrimSpace(viper.GetString("lark.app_id")), 111 - LarkAppSecret: strings.TrimSpace(viper.GetString("lark.app_secret")), 112 - LarkBaseURL: strings.TrimSpace(viper.GetString("lark.base_url")), 113 - ContactsFailureCooldown: consoleContactsFailureCooldownFromViper(), 113 + SlackBotToken: strings.TrimSpace(r.GetString("slack.bot_token")), 114 + SlackBaseURL: strings.TrimSpace(r.GetString("slack.base_url")), 115 + LineChannelAccessToken: strings.TrimSpace(r.GetString("line.channel_access_token")), 116 + LineBaseURL: strings.TrimSpace(r.GetString("line.base_url")), 117 + LarkAppID: strings.TrimSpace(r.GetString("lark.app_id")), 118 + LarkAppSecret: strings.TrimSpace(r.GetString("lark.app_secret")), 119 + LarkBaseURL: strings.TrimSpace(r.GetString("lark.base_url")), 120 + ContactsFailureCooldown: consoleContactsFailureCooldownFromReader(r), 114 121 } 115 122 } 116 123 117 124 func buildConsoleBaseRegistry(ctx context.Context, logger *slog.Logger) (*tools.Registry, *mcphost.Host) { 125 + return buildConsoleBaseRegistryFromReader(ctx, logger, viper.GetViper()) 126 + } 127 + 128 + func buildConsoleBaseRegistryFromReader(ctx context.Context, logger *slog.Logger, r *viper.Viper) (*tools.Registry, *mcphost.Host) { 118 129 if logger == nil { 119 130 logger = slog.Default() 120 131 } 121 - cfg := loadConsoleRegistryConfigFromViper() 132 + cfg := loadConsoleRegistryConfigFromReader(r) 122 133 reg := tools.NewRegistry() 123 134 124 135 allowProfiles := make(map[string]bool) ··· 206 217 }, 207 218 }, nil) 208 219 209 - host, err := mcphost.RegisterTools(ctx, mcphost.MCPConfigFromViper(), reg, logger) 220 + host, err := mcphost.RegisterTools(ctx, mcphost.MCPConfigFromReader(r), reg, logger) 210 221 if err != nil { 211 222 logger.Warn("mcp_init_failed", "err", err) 212 223 } ··· 214 225 } 215 226 216 227 func buildConsoleGuardFromViper(logger *slog.Logger) *guard.Guard { 228 + return buildConsoleGuardFromReader(logger, viper.GetViper()) 229 + } 230 + 231 + func buildConsoleGuardFromReader(logger *slog.Logger, r *viper.Viper) *guard.Guard { 217 232 if logger == nil { 218 233 logger = slog.Default() 219 234 } 235 + if r == nil { 236 + return nil 237 + } 220 238 221 239 var patterns []guard.RegexPattern 222 - _ = viper.UnmarshalKey("guard.redaction.patterns", &patterns) 240 + _ = r.UnmarshalKey("guard.redaction.patterns", &patterns) 223 241 224 242 cfg := guard.Config{ 225 243 Enabled: true, 226 244 Network: guard.NetworkConfig{ 227 245 URLFetch: guard.URLFetchNetworkPolicy{ 228 - AllowedURLPrefixes: append([]string(nil), viper.GetStringSlice("guard.network.url_fetch.allowed_url_prefixes")...), 229 - DenyPrivateIPs: viper.GetBool("guard.network.url_fetch.deny_private_ips"), 230 - FollowRedirects: viper.GetBool("guard.network.url_fetch.follow_redirects"), 231 - AllowProxy: viper.GetBool("guard.network.url_fetch.allow_proxy"), 246 + AllowedURLPrefixes: append([]string(nil), r.GetStringSlice("guard.network.url_fetch.allowed_url_prefixes")...), 247 + DenyPrivateIPs: r.GetBool("guard.network.url_fetch.deny_private_ips"), 248 + FollowRedirects: r.GetBool("guard.network.url_fetch.follow_redirects"), 249 + AllowProxy: r.GetBool("guard.network.url_fetch.allow_proxy"), 232 250 }, 233 251 }, 234 252 Redaction: guard.RedactionConfig{ 235 - Enabled: viper.GetBool("guard.redaction.enabled"), 253 + Enabled: r.GetBool("guard.redaction.enabled"), 236 254 Patterns: append([]guard.RegexPattern(nil), patterns...), 237 255 }, 238 256 Audit: guard.AuditConfig{ 239 - JSONLPath: strings.TrimSpace(viper.GetString("guard.audit.jsonl_path")), 240 - RotateMaxBytes: viper.GetInt64("guard.audit.rotate_max_bytes"), 257 + JSONLPath: strings.TrimSpace(r.GetString("guard.audit.jsonl_path")), 258 + RotateMaxBytes: r.GetInt64("guard.audit.rotate_max_bytes"), 241 259 }, 242 260 Approvals: guard.ApprovalsConfig{ 243 - Enabled: viper.GetBool("guard.approvals.enabled"), 261 + Enabled: r.GetBool("guard.approvals.enabled"), 244 262 }, 245 263 } 246 - if !viper.GetBool("guard.enabled") { 264 + if !r.GetBool("guard.enabled") { 247 265 return nil 248 266 } 249 267 250 - guardDir := resolveConsoleGuardDir(viper.GetString("file_state_dir"), viper.GetString("guard.dir_name")) 268 + guardDir := resolveConsoleGuardDir(r.GetString("file_state_dir"), r.GetString("guard.dir_name")) 251 269 if err := os.MkdirAll(guardDir, 0o700); err != nil { 252 270 logger.Warn("guard_dir_create_error", "error", err.Error(), "guard_dir", guardDir) 253 271 return nil ··· 314 332 } 315 333 316 334 func consoleAgentConfigFromViper() agent.Config { 335 + return consoleAgentConfigFromReader(viper.GetViper()) 336 + } 337 + 338 + func consoleAgentConfigFromReader(r interface { 339 + GetInt(string) int 340 + }) agent.Config { 341 + if r == nil { 342 + return agent.Config{} 343 + } 317 344 return agent.Config{ 318 - MaxSteps: viper.GetInt("max_steps"), 319 - ParseRetries: viper.GetInt("parse_retries"), 320 - MaxTokenBudget: viper.GetInt("max_token_budget"), 321 - ToolRepeatLimit: viper.GetInt("tool_repeat_limit"), 345 + MaxSteps: r.GetInt("max_steps"), 346 + ParseRetries: r.GetInt("parse_retries"), 347 + MaxTokenBudget: r.GetInt("max_token_budget"), 348 + ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 322 349 } 323 350 } 324 351 325 352 func consoleEngineToolsConfigFromViper() agent.EngineToolsConfig { 353 + return consoleEngineToolsConfigFromReader(viper.GetViper()) 354 + } 355 + 356 + func consoleEngineToolsConfigFromReader(r interface { 357 + GetBool(string) bool 358 + }) agent.EngineToolsConfig { 359 + if r == nil { 360 + return agent.EngineToolsConfig{} 361 + } 326 362 return agent.EngineToolsConfig{ 327 - SpawnEnabled: viper.GetBool("tools.spawn.enabled"), 328 - ACPSpawnEnabled: viper.GetBool("tools.acp_spawn.enabled"), 363 + SpawnEnabled: r.GetBool("tools.spawn.enabled"), 364 + ACPSpawnEnabled: r.GetBool("tools.acp_spawn.enabled"), 329 365 } 330 366 } 331 367 ··· 341 377 } 342 378 343 379 func consoleContactsFailureCooldownFromViper() time.Duration { 344 - if viper.IsSet("contacts.proactive.failure_cooldown") { 345 - if v := viper.GetDuration("contacts.proactive.failure_cooldown"); v > 0 { 380 + return consoleContactsFailureCooldownFromReader(viper.GetViper()) 381 + } 382 + 383 + func consoleContactsFailureCooldownFromReader(r *viper.Viper) time.Duration { 384 + if r == nil { 385 + return 72 * time.Hour 386 + } 387 + if r.IsSet("contacts.proactive.failure_cooldown") { 388 + if v := r.GetDuration("contacts.proactive.failure_cooldown"); v > 0 { 346 389 return v 347 390 } 348 391 }
+103 -9
cmd/mistermorph/consolecmd/serve.go
··· 11 11 "fmt" 12 12 "io" 13 13 "io/fs" 14 + "log/slog" 14 15 "mime" 15 16 "net" 16 17 "net/http" ··· 42 43 passwordHash string 43 44 endpoints []runtimeEndpointConfig 44 45 endpointWarnings []string 45 - managedKinds []string 46 46 stateDir string 47 + configPath string 48 + runtimeOverrides consoleRuntimeOverrides 47 49 } 48 50 49 51 type runtimeEndpointConfig struct { ··· 165 167 } 166 168 167 169 stateDir := pathutil.ResolveStateDir(viper.GetString("file_state_dir")) 170 + configPath, err := resolveConsoleConfigPath() 171 + if err != nil { 172 + return serveConfig{}, err 173 + } 168 174 var rawEndpoints []runtimeEndpointConfigRaw 169 175 if err := viper.UnmarshalKey("console.endpoints", &rawEndpoints); err != nil { 170 176 return serveConfig{}, fmt.Errorf("invalid console.endpoints: %w", err) 171 177 } 172 178 endpoints, endpointWarnings := resolveRuntimeEndpointsForServe(rawEndpoints) 173 - managedKinds, err := normalizeManagedRuntimeKinds(viper.GetStringSlice("console.managed_runtimes")) 174 - if err != nil { 175 - return serveConfig{}, err 176 - } 177 179 return serveConfig{ 178 180 listen: listen, 179 181 basePath: basePath, ··· 187 189 passwordHash: viper.GetString("console.password_hash"), 188 190 endpoints: endpoints, 189 191 endpointWarnings: endpointWarnings, 190 - managedKinds: managedKinds, 191 192 stateDir: stateDir, 193 + configPath: configPath, 194 + runtimeOverrides: captureConsoleRuntimeOverrides(cmd), 192 195 }, nil 193 196 } 194 197 ··· 317 320 sessionStorePath = filepath.Join(cfg.stateDir, "console", "sessions.json") 318 321 } 319 322 320 - localRuntime, err := newConsoleLocalRuntime(cfg) 323 + reader, err := loadConsoleRuntimeConfig(cfg.configPath, cfg.runtimeOverrides) 324 + if err != nil { 325 + fallbackReader, fallbackErr := loadConsoleRuntimeConfig("", cfg.runtimeOverrides) 326 + if fallbackErr != nil { 327 + return nil, fallbackErr 328 + } 329 + _, _ = fmt.Fprintf(os.Stderr, "warn: console runtime config invalid; starting with defaults-only snapshot: %v\n", err) 330 + reader = fallbackReader 331 + } 332 + 333 + localRuntime, err := newConsoleLocalRuntime(cfg, reader) 321 334 if err != nil { 322 335 return nil, err 323 336 } 324 - managed := newManagedRuntimeSupervisor(localRuntime, cfg) 337 + managed := newManagedRuntimeSupervisor(localRuntime, cfg.inspectPrompt, cfg.inspectRequest) 338 + if err := managed.ReloadConfig(reader); err != nil { 339 + localRuntime.Close() 340 + return nil, err 341 + } 325 342 326 343 endpoints := make([]runtimeEndpoint, 0, len(cfg.endpoints)+1) 327 344 endpointByRef := make(map[string]runtimeEndpoint, len(cfg.endpoints)+1) ··· 361 378 if s != nil && s.managed != nil { 362 379 defer s.managed.Close() 363 380 } 381 + runCtx, cancelRun := context.WithCancel(context.Background()) 382 + defer cancelRun() 364 383 365 384 mux := http.NewServeMux() 366 385 apiPrefix := joinBasePath(s.cfg.basePath, "/api") ··· 402 421 } 403 422 fatalErrCh := make(chan error, 1) 404 423 if s != nil && s.managed != nil { 405 - if err := s.managed.Start(context.Background(), func(err error) { 424 + if err := s.managed.Start(runCtx, func(err error) { 406 425 if err == nil { 407 426 return 408 427 } ··· 410 429 case fatalErrCh <- err: 411 430 default: 412 431 } 432 + cancelRun() 413 433 _ = ln.Close() 414 434 _ = httpSrv.Close() 415 435 }); err != nil { ··· 417 437 return err 418 438 } 419 439 } 440 + s.startRuntimeConfigPoller(runCtx) 420 441 fmt.Fprintf(os.Stdout, "console serve listening on http://%s%s\n", ln.Addr().String(), displayBasePath(s.cfg.basePath)) 421 442 if !s.cfg.staticAssetsEnabled() { 422 443 fmt.Fprintf(os.Stdout, "console serve static assets disabled; API available under http://%s%s\n", ln.Addr().String(), apiPrefix) 423 444 } 424 445 err = httpSrv.Serve(ln) 446 + cancelRun() 425 447 if err != nil && !isBenignServeCloseError(err) { 426 448 return err 427 449 } ··· 435 457 436 458 func isBenignServeCloseError(err error) bool { 437 459 return err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) 460 + } 461 + 462 + func (s *server) logger() *slog.Logger { 463 + if s != nil && s.localRuntime != nil && s.localRuntime.logger != nil { 464 + return s.localRuntime.logger 465 + } 466 + return slog.Default() 467 + } 468 + 469 + func (s *server) currentRuntimeConfigReader() *viper.Viper { 470 + if s != nil && s.localRuntime != nil { 471 + if reader := s.localRuntime.currentConfigReader(); reader != nil { 472 + return reader 473 + } 474 + } 475 + return viper.GetViper() 476 + } 477 + 478 + func (s *server) startRuntimeConfigPoller(ctx context.Context) { 479 + if s == nil || strings.TrimSpace(s.cfg.configPath) == "" { 480 + return 481 + } 482 + lastFingerprint, err := fingerprintConfigPath(s.cfg.configPath) 483 + if err != nil { 484 + s.logger().Warn("console_runtime_config_poll_stat_failed", "error", err.Error()) 485 + } 486 + go func() { 487 + ticker := time.NewTicker(consoleConfigPollInterval) 488 + defer ticker.Stop() 489 + for { 490 + select { 491 + case <-ctx.Done(): 492 + return 493 + case <-ticker.C: 494 + nextFingerprint, err := fingerprintConfigPath(s.cfg.configPath) 495 + if err != nil { 496 + s.logger().Warn("console_runtime_config_poll_stat_failed", "error", err.Error()) 497 + continue 498 + } 499 + if nextFingerprint == lastFingerprint { 500 + continue 501 + } 502 + lastFingerprint = nextFingerprint 503 + if err := s.reloadRuntimeConfig(); err != nil { 504 + s.logger().Warn("console_runtime_reload_failed", "error", err.Error()) 505 + continue 506 + } 507 + s.logger().Info("console_runtime_reloaded", "config_path", strings.TrimSpace(s.cfg.configPath)) 508 + } 509 + } 510 + }() 511 + } 512 + 513 + func (s *server) reloadRuntimeConfig() error { 514 + if s == nil { 515 + return nil 516 + } 517 + reader, err := loadConsoleRuntimeConfig(s.cfg.configPath, s.cfg.runtimeOverrides) 518 + if err != nil { 519 + return err 520 + } 521 + if s.localRuntime != nil { 522 + if err := s.localRuntime.ReloadAgentConfigFromReader(reader); err != nil { 523 + return err 524 + } 525 + } 526 + if s.managed != nil { 527 + if err := s.managed.ReloadConfig(reader); err != nil { 528 + return err 529 + } 530 + } 531 + return nil 438 532 } 439 533 440 534 func (s *server) withAuth(next http.HandlerFunc) http.HandlerFunc {
-28
cmd/mistermorph/consolecmd/serve_test.go
··· 293 293 } 294 294 } 295 295 296 - func TestLoadServeConfigManagedRuntimes(t *testing.T) { 297 - viper.Reset() 298 - t.Cleanup(viper.Reset) 299 - viper.Set("console.managed_runtimes", []string{" telegram ", "slack", "telegram"}) 300 - 301 - cfg, err := loadServeConfig(newServeCmd()) 302 - if err != nil { 303 - t.Fatalf("loadServeConfig() error = %v", err) 304 - } 305 - if len(cfg.managedKinds) != 2 { 306 - t.Fatalf("len(cfg.managedKinds) = %d, want 2", len(cfg.managedKinds)) 307 - } 308 - if cfg.managedKinds[0] != "telegram" || cfg.managedKinds[1] != "slack" { 309 - t.Fatalf("cfg.managedKinds = %#v, want [telegram slack]", cfg.managedKinds) 310 - } 311 - } 312 - 313 - func TestLoadServeConfigRejectsUnsupportedManagedRuntime(t *testing.T) { 314 - viper.Reset() 315 - t.Cleanup(viper.Reset) 316 - viper.Set("console.managed_runtimes", []string{"telegram", "line"}) 317 - 318 - _, err := loadServeConfig(newServeCmd()) 319 - if err == nil || !strings.Contains(err.Error(), "unsupported console.managed_runtimes entry") { 320 - t.Fatalf("loadServeConfig() error = %v, want unsupported managed runtime", err) 321 - } 322 - } 323 - 324 296 func TestIsBenignServeCloseError(t *testing.T) { 325 297 if !isBenignServeCloseError(nil) { 326 298 t.Fatalf("nil error should be benign")
-31
cmd/mistermorph/consolecmd/setup_repair.go
··· 8 8 "path/filepath" 9 9 "strings" 10 10 11 - "github.com/quailyquaily/mistermorph/integration" 12 11 "github.com/quailyquaily/mistermorph/internal/onboardingcheck" 13 12 "github.com/quailyquaily/mistermorph/internal/pathutil" 14 13 "github.com/spf13/viper" ··· 72 71 writeError(w, http.StatusInternalServerError, err.Error()) 73 72 return 74 73 } 75 - if item.Key == onboardingcheck.FileKeyConfig { 76 - if next := onboardingcheck.InspectConfigPath(item.Path); next.Status == onboardingcheck.StatusOK { 77 - if err := s.reloadAgentSettingsFromConfigPath(item.Path); err != nil { 78 - writeError(w, http.StatusInternalServerError, err.Error()) 79 - return 80 - } 81 - } 82 - } 83 74 next, err := s.resolveSetupRepairFile(item.Key) 84 75 if err != nil { 85 76 writeError(w, http.StatusInternalServerError, err.Error()) ··· 117 108 return s.cfg.stateDir 118 109 } 119 110 return pathutil.ResolveStateDir(viper.GetString("file_state_dir")) 120 - } 121 - 122 - func (s *server) reloadAgentSettingsFromConfigPath(configPath string) error { 123 - tmp := viper.New() 124 - integration.ApplyViperDefaults(tmp) 125 - if err := readExpandedConsoleConfig(tmp, configPath); err != nil { 126 - return err 127 - } 128 - viper.Set(llmSettingsKey, tmp.Get(llmSettingsKey)) 129 - viper.Set(multimodalSettingsKey, tmp.Get(multimodalSettingsKey)) 130 - viper.Set(toolsSettingsKey, tmp.Get(toolsSettingsKey)) 131 - if s != nil && s.localRuntime != nil { 132 - if err := s.localRuntime.ReloadAgentConfig(); err != nil { 133 - return err 134 - } 135 - } 136 - if s != nil && s.managed != nil { 137 - if err := s.managed.Restart(); err != nil { 138 - return err 139 - } 140 - } 141 - return nil 142 111 } 143 112 144 113 func writeRepairFile(path string, content []byte) error {
+12
cmd/mistermorph/consolecmd/streaming.go
··· 109 109 } 110 110 } 111 111 112 + func (h *consoleStreamHub) Latest(taskID string) (consoleStreamFrame, bool) { 113 + if h == nil { 114 + return consoleStreamFrame{}, false 115 + } 116 + taskID = strings.TrimSpace(taskID) 117 + 118 + h.mu.RLock() 119 + frame, ok := h.latest[taskID] 120 + h.mu.RUnlock() 121 + return frame, ok 122 + } 123 + 112 124 func (h *consoleStreamHub) publish(frame consoleStreamFrame) { 113 125 if h == nil || strings.TrimSpace(frame.TaskID) == "" { 114 126 return
+37 -13
cmd/mistermorph/consolecmd/streaming_test.go
··· 23 23 taskID := "task-no-subscriber" 24 24 25 25 hub.PublishSnapshot(taskID, "partial output") 26 - if _, ok := hub.latest[taskID]; !ok { 26 + if _, ok := hub.Latest(taskID); !ok { 27 27 t.Fatal("latest snapshot missing before completion") 28 28 } 29 29 30 30 hub.PublishFinal(taskID, "final output") 31 - if _, ok := hub.latest[taskID]; ok { 31 + if _, ok := hub.Latest(taskID); ok { 32 32 t.Fatal("latest entry retained after done frame without subscribers") 33 33 } 34 34 } ··· 40 40 _, unsubscribe := hub.Subscribe(taskID) 41 41 hub.PublishFinal(taskID, "final output") 42 42 43 - latest, ok := hub.latest[taskID] 43 + latest, ok := hub.Latest(taskID) 44 44 if !ok { 45 45 t.Fatal("latest entry missing while subscriber is connected") 46 46 } ··· 49 49 } 50 50 51 51 unsubscribe() 52 - if _, ok := hub.latest[taskID]; ok { 52 + if _, ok := hub.Latest(taskID); ok { 53 53 t.Fatal("latest entry retained after last subscriber unsubscribed") 54 54 } 55 55 } ··· 81 81 Status: "done", 82 82 }) 83 83 84 - frame, ok := hub.latest["task-preview"] 84 + frame, ok := hub.Latest("task-preview") 85 85 if !ok { 86 86 t.Fatal("expected preview frame") 87 87 } ··· 108 108 ToolName: "bash", 109 109 Status: "running", 110 110 }) 111 - startSeq := hub.latest["task-throttle"].Seq 111 + startFrame, ok := hub.Latest("task-throttle") 112 + if !ok { 113 + t.Fatal("expected initial throttle frame") 114 + } 115 + startSeq := startFrame.Seq 112 116 113 117 sink.HandleEvent(context.Background(), agent.Event{ 114 118 Kind: agent.EventKindToolOutput, ··· 117 121 Stream: "stdout", 118 122 Text: "a", 119 123 }) 120 - firstOutputSeq := hub.latest["task-throttle"].Seq 124 + firstOutputFrame, ok := hub.Latest("task-throttle") 125 + if !ok { 126 + t.Fatal("expected first throttle output frame") 127 + } 128 + firstOutputSeq := firstOutputFrame.Seq 121 129 if firstOutputSeq <= startSeq { 122 130 t.Fatalf("first output should publish immediately, startSeq=%d firstOutputSeq=%d", startSeq, firstOutputSeq) 123 131 } ··· 129 137 Stream: "stdout", 130 138 Text: "b", 131 139 }) 132 - if hub.latest["task-throttle"].Seq != firstOutputSeq { 140 + currentFrame, ok := hub.Latest("task-throttle") 141 + if !ok { 142 + t.Fatal("expected throttle frame after small output") 143 + } 144 + if currentFrame.Seq != firstOutputSeq { 133 145 t.Fatalf("small incremental output should not publish immediately") 134 146 } 135 147 ··· 140 152 Stream: "stdout", 141 153 Text: strings.Repeat("x", 300), 142 154 }) 143 - if hub.latest["task-throttle"].Seq == firstOutputSeq { 155 + currentFrame, ok = hub.Latest("task-throttle") 156 + if !ok { 157 + t.Fatal("expected throttle frame after large output") 158 + } 159 + if currentFrame.Seq == firstOutputSeq { 144 160 t.Fatalf("large incremental output should trigger publish") 145 161 } 146 162 } ··· 156 172 Profile: string(agent.ObserveProfileWebExtract), 157 173 Status: "running", 158 174 }) 159 - startSeq := hub.latest["task-web"].Seq 175 + startFrame, ok := hub.Latest("task-web") 176 + if !ok { 177 + t.Fatal("expected web preview frame") 178 + } 179 + startSeq := startFrame.Seq 160 180 161 181 sink.HandleEvent(context.Background(), agent.Event{ 162 182 Kind: agent.EventKindToolOutput, ··· 164 184 Stream: "stdout", 165 185 Text: "<html>noise</html>", 166 186 }) 167 - if hub.latest["task-web"].Seq != startSeq { 187 + currentFrame, ok := hub.Latest("task-web") 188 + if !ok { 189 + t.Fatal("expected web preview frame after suppressed output") 190 + } 191 + if currentFrame.Seq != startSeq { 168 192 t.Fatalf("web_extract raw output should stay suppressed before terminal event") 169 193 } 170 194 } ··· 196 220 197 221 deadline := time.Now().Add(2 * time.Second) 198 222 for time.Now().Before(deadline) { 199 - frame, ok := hub.latest["task-observe"] 223 + frame, ok := hub.Latest("task-observe") 200 224 if ok && strings.Contains(frame.Text, "summary:\nFound candidate article list") { 201 225 return 202 226 } 203 227 time.Sleep(10 * time.Millisecond) 204 228 } 205 - frame, ok := hub.latest["task-observe"] 229 + frame, ok := hub.Latest("task-observe") 206 230 if !ok { 207 231 t.Fatal("expected observer preview frame") 208 232 }
+2 -2
cmd/mistermorph/install_config_wizard_test.go
··· 143 143 if gotAPIKey := cfg.GetString("llm.api_key"); gotAPIKey != "" { 144 144 t.Fatalf("llm.api_key = %q, want empty for cloudflare", gotAPIKey) 145 145 } 146 - if gotSources := cfg.GetStringSlice("multimodal.image.sources"); len(gotSources) != 0 { 147 - t.Fatalf("multimodal.image.sources = %#v, want empty", gotSources) 146 + if gotSources := cfg.GetStringSlice("multimodal.image.sources"); len(gotSources) != 2 || gotSources[0] != "telegram" || gotSources[1] != "line" { 147 + t.Fatalf("multimodal.image.sources = %#v, want [telegram line]", gotSources) 148 148 } 149 149 var endpoints []map[string]any 150 150 if err := cfg.UnmarshalKey("console.endpoints", &endpoints); err != nil {
-10
cmd/mistermorph/root.go
··· 83 83 _ = viper.BindPFlag("logging.max_skill_content_chars", cmd.PersistentFlags().Lookup("log-max-skill-content-chars")) 84 84 _ = viper.BindPFlag("logging.redact_keys", cmd.PersistentFlags().Lookup("log-redact-key")) 85 85 86 - viper.SetDefault("logging.format", "text") 87 - viper.SetDefault("logging.add_source", false) 88 - viper.SetDefault("logging.include_thoughts", true) 89 - viper.SetDefault("logging.include_tool_params", true) 90 - viper.SetDefault("logging.include_skill_contents", false) 91 - viper.SetDefault("logging.max_thought_chars", 2000) 92 - viper.SetDefault("logging.max_json_bytes", 32*1024) 93 - viper.SetDefault("logging.max_string_value_chars", 2000) 94 - viper.SetDefault("logging.max_skill_content_chars", 8000) 95 - 96 86 registryResolver := newRegistryRuntimeResolver() 97 87 guardResolver := newGuardRuntimeResolver() 98 88 telegramLLM := newLLMRuntimeResolver()
+111
docs/configuration.md
··· 32 32 33 33 All string values in config support `${ENV_VAR}` expansion. 34 34 35 + ## Runtime Model 36 + 37 + There are two different config lifecycles: 38 + 39 + - one-shot commands such as `run`, `telegram`, and `slack` 40 + - the long-running `console serve` process 41 + 42 + One-shot commands are simple: 43 + 44 + ```text 45 + process start 46 + | 47 + v 48 + load config once 49 + | 50 + v 51 + run with that config until process exit 52 + ``` 53 + 54 + `console serve` is different because the process stays alive. It uses runtime snapshots. 55 + 56 + Resolved console config path: 57 + 58 + - `--config`, if explicitly set 59 + - otherwise the first existing file in `config.yaml`, `~/.morph/config.yaml` 60 + - if neither exists, the default write target is local `config.yaml` 61 + 62 + Snapshot build flow: 63 + 64 + ```text 65 + startup / config file change 66 + | 67 + v 68 + +-------------------------------------------+ 69 + | loadConsoleRuntimeConfig(configPath, ...) | 70 + | ----------------------------------------- | 71 + | 1. shared defaults | 72 + | 2. MISTER_MORPH_* env | 73 + | 3. captured runtime flag overrides | 74 + | current code: inherited --log-* flags | 75 + | 4. read + ${ENV_VAR} expand config.yaml | 76 + +-------------------------------------------+ 77 + | 78 + v 79 + +---------------------+ 80 + | immutable snapshot | 81 + | reader: *viper.Viper| 82 + +---------------------+ 83 + | | 84 + v v 85 + +----------------+ +----------------------+ 86 + | Console Local | | Managed Runtimes | 87 + | in-process rt | | telegram / slack | 88 + +----------------+ +----------------------+ 89 + ``` 90 + 91 + What this means in practice: 92 + 93 + - The runtime does not use the global process `viper` as live mutable state. 94 + - A running `console serve` instance works from its current snapshot. 95 + - When `config.yaml` changes, a new snapshot is built and swapped in. 96 + - If rebuilding fails, the old snapshot keeps running. 97 + 98 + ## Console Update Path 99 + 100 + The console Web API and setup repair path do not mutate runtime state directly. They only write YAML to the resolved config path. 101 + 102 + ```text 103 + browser / repair UI 104 + | 105 + v 106 + PUT /api/settings/* or PUT /api/setup/file?key=config 107 + | 108 + v 109 + write config.yaml only 110 + | 111 + v 112 + no direct global viper mutation 113 + no direct runtime restart call 114 + | 115 + v 116 + console config poller notices file fingerprint change 117 + | 118 + v 119 + build new snapshot 120 + | | 121 + | success | failure 122 + v v 123 + swap local runtime keep old snapshot 124 + reload managed runtimes log warning 125 + ``` 126 + 127 + This separation is intentional: 128 + 129 + - the write path is responsible only for durable config 130 + - the runtime layer is responsible only for consuming snapshots 131 + - concurrency stays inside each runtime instance, not inside the config writer 132 + 133 + ## Console Startup With Invalid Config 134 + 135 + `console serve` tries to build a runtime snapshot from the resolved config path at startup. 136 + 137 + If the config file is invalid: 138 + 139 + - the HTTP server still starts 140 + - the runtime falls back to a defaults-only snapshot 141 + - the setup repair UI can fix `config.yaml` 142 + - later successful file changes replace the fallback snapshot 143 + 144 + This avoids a deadlock where a broken config prevents the repair UI from starting. 145 + 35 146 ## Common CLI Flags 36 147 37 148 Global flags:
+67 -81
docs/feat/feat_20260418_config_defaults_sources.md
··· 8 8 9 9 ## 1) Goal 10 10 11 - This note explains where configuration defaults come from today, why there are two defaulting entrypoints, where each one is used, and which one should become the shared authority. 11 + This note records the default-config decision for `mistermorph`. 12 + 13 + The target is simple: 12 14 13 - This is intentionally documented outside the current PowerShell PR scope. 15 + - there is only one source of truth for shared defaults 16 + - the default process runtime and `integration` see the same defaults 17 + - config snapshot lifecycle is separate from config file editing 18 + 19 + This note is intentionally limited to defaults and snapshot boundaries. 20 + It does not define a hot-reload mechanism. 14 21 15 - ## 2) Current Defaulting Entrypoints 22 + ## 2) Defaulting Shape 16 23 17 - There are currently two functions that apply defaults to a `viper.Viper` instance: 24 + There are still two call paths that can write defaults into a `viper.Viper` instance: 18 25 19 26 - `internal/configdefaults.Apply` 20 27 - `integration.ApplyViperDefaults` 21 28 22 - They overlap heavily. 29 + But they no longer define defaults independently. 23 30 24 - Both set defaults for common runtime and tool keys such as: 31 + The shared authority is: 25 32 26 - - `llm.*` 27 - - agent limits like `max_steps` 28 - - file state/cache directories 29 - - `tools.read_file.*` 30 - - `tools.write_file.*` 31 - - `tools.bash.*` 32 - - `tools.powershell.*` 33 - - `tools.url_fetch.*` 34 - - `tools.web_search.*` 33 + - `internal/configdefaults.Apply` 35 34 36 - ## 3) `internal/configdefaults.Apply` 35 + The compatibility entrypoint is: 37 36 38 - This is the main program default source for the global CLI/runtime `viper`. 37 + - `integration.ApplyViperDefaults` 39 38 40 - It should be treated as the repo-wide authority for shared defaults. 39 + `integration.ApplyViperDefaults` delegates to `internal/configdefaults.Apply`. 40 + It exists because `integration` is a public package and some callers want a direct helper that writes defaults into their own `viper` instance. 41 + 42 + ## 3) Shared Authority 43 + 44 + `internal/configdefaults.Apply` is the repo-wide authority for shared defaults. 45 + 46 + It defines shared runtime defaults such as: 47 + 48 + - `llm.*` 49 + - agent limits like `max_steps` 50 + - file state/cache directories 51 + - `logging.*` 52 + - `tasks.*` 53 + - `multimodal.image.sources` 54 + - `console.*` 55 + - `telegram.*` 56 + - `slack.*` 57 + - `line.*` 58 + - `lark.*` 59 + - `tools.*` 41 60 42 61 Primary uses: 43 62 44 63 - CLI initialization through `initConfig -> initViperDefaults` 45 64 - main registry loading from the global `viper` 65 + - isolated readers through delegation from `integration.ApplyViperDefaults` 46 66 47 67 Relevant code: 48 68 49 69 - `cmd/mistermorph/defaults.go` 50 70 - `cmd/mistermorph/root.go` 51 71 - `cmd/mistermorph/registry.go` 52 - 53 - Operationally, this is the defaulting path for the normal application runtime: 54 - 55 - - `run` 56 - - `console` 57 - - `telegram` 58 - - `slack` 59 - - `line` 60 - - `lark` 61 - 62 - as long as they are reading from the process-wide `viper`. 72 + - `internal/configdefaults/defaults.go` 63 73 64 74 ## 4) `integration.ApplyViperDefaults` 65 75 66 - This is used when code creates a fresh temporary `viper` instance and wants a self-contained config reader. 76 + This remains the public helper for callers that construct a fresh `viper` instance and want the standard default set. 67 77 68 - It is better understood as a construction helper for isolated readers, not as an independent authority. 78 + It is not an independent authority. 79 + It is a compatibility and convenience entrypoint for `integration`. 69 80 70 81 Primary uses: 71 82 ··· 88 99 - config validation against a temporary reader 89 100 - integration runtime bootstrapping from explicit overrides 90 101 91 - ## 5) Why Two Sources Exist 102 + Because it delegates to the shared authority, the default process runtime and `integration` now see the same defaults. 92 103 93 - From a first-principles view, the two functions solve different construction modes: 104 + ## 5) Logging Defaults 94 105 95 - - one for the global process config 96 - - one for isolated temporary readers 106 + `logging.*` belongs to the shared default set and should live in `internal/configdefaults.Apply`. 97 107 98 - That distinction is reasonable. 108 + CLI logging flags are an input surface, not an independent default authority. 109 + Their job is to override when the user explicitly passes a flag. 110 + They should not carry a separate runtime truth. 99 111 100 - What is not ideal is that the defaults themselves are duplicated instead of sharing a single authority. 112 + ## 6) Why This Cleanup Was Needed 101 113 102 - ## 6) Current Problem 114 + Before this cleanup, the two call paths had already drifted. 103 115 104 - The current issue is not that there are two call paths. 105 - The current issue is that both paths each define defaults directly. 116 + Examples that had already diverged: 106 117 107 - This is already causing real drift, not just theoretical duplication. 108 - 109 - Examples: 110 - 111 - - `internal/configdefaults.Apply` contains keys that are absent from `integration.ApplyViperDefaults`, such as: 112 - - `tasks.*` 113 - - `multimodal.image.sources` 114 - - `console.*` 115 - - `line.*` 116 - - `lark.*` 117 - - `telegram.serve_listen` 118 - - `slack.serve_listen` 119 - - `integration.ApplyViperDefaults` contains `logging.*`, while `internal/configdefaults.Apply` does not 120 - - at least one shared key already disagrees: 118 + - `multimodal.image.sources` existed in one path but not the other 119 + - `logging.*` existed in a different place from the rest of the shared defaults 120 + - at least one shared key had conflicting values: 121 121 - `telegram.addressing_interject_threshold` 122 - - `internal/configdefaults.Apply` sets `0.6` 123 - - `integration.ApplyViperDefaults` sets `0.3` 124 122 125 - That means whenever a new config key is added, especially under `tools.*`, there is a risk of: 123 + That kind of duplication is enough to make generated config, console views, and runtime behavior disagree. 126 124 127 - - updating one defaults function but not the other 128 - - drifting platform-specific behavior 129 - - drifting Console Settings behavior from main runtime behavior 125 + ## 7) Snapshot Boundary 130 126 131 - This is the structural reason the PowerShell work had to touch both files. 127 + Defaults authority and snapshot lifecycle are related but not the same concern. 132 128 133 - ## 7) Recommended Direction 129 + The intended runtime model is: 134 130 135 - The likely cleanup direction is: 131 + - each runtime operates on its own config snapshot 132 + - a snapshot is rebuilt from resolved config input 133 + - each runtime is responsible for its own concurrency safety 136 134 137 - - keep both call paths 138 - - reduce to one authority for shared defaults 135 + The intended config editing model is: 139 136 140 - Possible shape: 137 + - Console Web API edits `config.yaml` 138 + - editing `config.yaml` does not itself define snapshot refresh semantics 141 139 142 - - `internal/configdefaults.Apply` remains the shared source of truth 143 - - `integration.ApplyViperDefaults` calls into it first 144 - - `integration.ApplyViperDefaults` only adds integration-specific defaults if it truly owns any 145 - - shared `logging.*` defaults should also be moved under the same authority instead of living only in the isolated-reader path 146 - 147 - This keeps the useful construction split without duplicating the actual default values. 148 - 149 - ## 8) Recommendation For Scope 150 - 151 - This should be treated as a follow-up cleanup, not part of the PowerShell feature delivery itself. 140 + In other words: 152 141 153 - Reason: 154 - 155 - - it changes configuration architecture 156 - - it is broader than shell-tool support 157 - - it deserves a refactor-only change with regression checks around settings loading 142 + - config mutation and snapshot regeneration should not be fused into one hidden responsibility 143 + - reload, restart, file watch, or explicit regenerate flows should be treated as a separate design decision
+2 -142
integration/defaults.go
··· 1 1 package integration 2 2 3 3 import ( 4 - "time" 5 - 6 - "github.com/quailyquaily/mistermorph/internal/platformutil" 4 + "github.com/quailyquaily/mistermorph/internal/configdefaults" 7 5 "github.com/spf13/viper" 8 6 ) 9 7 ··· 11 9 if v == nil { 12 10 v = viper.GetViper() 13 11 } 14 - // Shared agent defaults. 15 - v.SetDefault("llm.provider", "openai") 16 - v.SetDefault("llm.endpoint", "") 17 - v.SetDefault("llm.model", "") 18 - v.SetDefault("llm.api_key", "") 19 - v.SetDefault("llm.request_timeout", 90*time.Second) 20 - v.SetDefault("llm.tools_emulation_mode", "off") 21 - v.SetDefault("llm.cloudflare.account_id", "") 22 - v.SetDefault("llm.cloudflare.api_token", "") 23 - 24 - v.SetDefault("max_steps", 15) 25 - v.SetDefault("parse_retries", 2) 26 - v.SetDefault("max_token_budget", 0) 27 - v.SetDefault("tool_repeat_limit", 3) 28 - v.SetDefault("timeout", 10*time.Minute) 29 - v.SetDefault("tools.plan_create.enabled", true) 30 - v.SetDefault("tools.plan_create.max_steps", 6) 31 - 32 - // Global. 33 - v.SetDefault("file_state_dir", "~/.morph") 34 - v.SetDefault("file_cache_dir", "~/.cache/morph") 35 - v.SetDefault("file_cache.max_age", 7*24*time.Hour) 36 - v.SetDefault("file_cache.max_files", 1000) 37 - v.SetDefault("file_cache.max_total_bytes", int64(512*1024*1024)) 38 - v.SetDefault("user_agent", "mistermorph/1.0 (+https://github.com/quailyquaily)") 39 - 40 - // Builtin tools. 41 - v.SetDefault("tools.read_file.max_bytes", 256*1024) 42 - v.SetDefault("tools.read_file.deny_paths", []string{"config.yaml"}) 43 - v.SetDefault("tools.write_file.enabled", true) 44 - v.SetDefault("tools.write_file.max_bytes", 512*1024) 45 - v.SetDefault("tools.spawn.enabled", true) 46 - v.SetDefault("tools.acp_spawn.enabled", false) 47 - if platformutil.IsWindows() { 48 - v.SetDefault("tools.bash.enabled", false) 49 - v.SetDefault("tools.powershell.enabled", true) 50 - } else { 51 - v.SetDefault("tools.bash.enabled", true) 52 - v.SetDefault("tools.powershell.enabled", false) 53 - } 54 - v.SetDefault("tools.bash.timeout", 30*time.Second) 55 - v.SetDefault("tools.bash.max_output_bytes", 256*1024) 56 - v.SetDefault("tools.bash.deny_paths", []string{"config.yaml"}) 57 - v.SetDefault("tools.bash.injected_env_vars", []string{}) 58 - v.SetDefault("tools.powershell.timeout", 30*time.Second) 59 - v.SetDefault("tools.powershell.max_output_bytes", 256*1024) 60 - v.SetDefault("tools.powershell.deny_paths", []string{"config.yaml"}) 61 - v.SetDefault("tools.powershell.injected_env_vars", []string{}) 62 - v.SetDefault("tools.url_fetch.enabled", true) 63 - v.SetDefault("tools.url_fetch.timeout", 30*time.Second) 64 - v.SetDefault("tools.url_fetch.max_bytes", int64(512*1024)) 65 - v.SetDefault("tools.url_fetch.max_bytes_download", int64(100*1024*1024)) 66 - v.SetDefault("tools.web_search.enabled", true) 67 - v.SetDefault("tools.web_search.timeout", 20*time.Second) 68 - v.SetDefault("tools.web_search.max_results", 5) 69 - v.SetDefault("tools.web_search.base_url", "https://duckduckgo.com/html/") 70 - v.SetDefault("tools.contacts_send.enabled", true) 71 - v.SetDefault("tools.todo_update.enabled", true) 72 - 73 - // Skills. 74 - v.SetDefault("skills.enabled", true) 75 - v.SetDefault("skills.dir_name", "skills") 76 - 77 - // Bus. 78 - v.SetDefault("bus.max_inflight", 1024) 79 - 80 - v.SetDefault("contacts.dir_name", "contacts") 81 - v.SetDefault("contacts.proactive.max_turns_per_session", 6) 82 - v.SetDefault("contacts.proactive.session_cooldown", 72*time.Hour) 83 - v.SetDefault("contacts.proactive.failure_cooldown", 72*time.Hour) 84 - 85 - v.SetDefault("server.max_queue", 100) 86 - 87 - // Submit client. 88 - v.SetDefault("submit.wait", false) 89 - v.SetDefault("submit.poll_interval", 1*time.Second) 90 - 91 - // Telegram. 92 - v.SetDefault("telegram.poll_timeout", 30*time.Second) 93 - v.SetDefault("telegram.group_trigger_mode", "smart") 94 - v.SetDefault("telegram.addressing_confidence_threshold", 0.6) 95 - v.SetDefault("telegram.addressing_interject_threshold", 0.3) 96 - v.SetDefault("telegram.max_concurrency", 3) 97 - 98 - // Slack. 99 - v.SetDefault("slack.base_url", "https://slack.com/api") 100 - v.SetDefault("slack.bot_token", "") 101 - v.SetDefault("slack.app_token", "") 102 - v.SetDefault("slack.allowed_team_ids", []string{}) 103 - v.SetDefault("slack.allowed_channel_ids", []string{}) 104 - v.SetDefault("slack.task_timeout", 0*time.Second) 105 - v.SetDefault("slack.max_concurrency", 3) 106 - v.SetDefault("slack.group_trigger_mode", "smart") 107 - v.SetDefault("slack.addressing_confidence_threshold", 0.6) 108 - v.SetDefault("slack.addressing_interject_threshold", 0.6) 109 - 110 - // Heartbeat. 111 - v.SetDefault("heartbeat.enabled", true) 112 - v.SetDefault("heartbeat.interval", 30*time.Minute) 113 - 114 - // Memory. 115 - v.SetDefault("memory.enabled", true) 116 - v.SetDefault("memory.dir_name", "memory") 117 - v.SetDefault("memory.short_term_days", 7) 118 - v.SetDefault("memory.injection.enabled", true) 119 - v.SetDefault("memory.injection.max_items", 50) 120 - 121 - // Secrets. 122 - v.SetDefault("secrets.allow_profiles", []string{}) 123 - v.SetDefault("auth_profiles", map[string]any{}) 124 - 125 - // MCP. 126 - v.SetDefault("mcp.servers", []map[string]any{}) 127 - v.SetDefault("acp.agents", []map[string]any{}) 128 - 129 - // Guard. 130 - v.SetDefault("guard.enabled", true) 131 - v.SetDefault("guard.network.url_fetch.allowed_url_prefixes", []string{"https://"}) 132 - v.SetDefault("guard.network.url_fetch.deny_private_ips", true) 133 - v.SetDefault("guard.network.url_fetch.follow_redirects", false) 134 - v.SetDefault("guard.network.url_fetch.allow_proxy", false) 135 - v.SetDefault("guard.redaction.enabled", true) 136 - v.SetDefault("guard.redaction.patterns", []map[string]any{}) 137 - v.SetDefault("guard.dir_name", "guard") 138 - v.SetDefault("guard.audit.jsonl_path", "") 139 - v.SetDefault("guard.audit.rotate_max_bytes", int64(100*1024*1024)) 140 - v.SetDefault("guard.approvals.enabled", false) 141 - 142 - // Logging. 143 - v.SetDefault("logging.level", "") 144 - v.SetDefault("logging.format", "text") 145 - v.SetDefault("logging.add_source", false) 146 - v.SetDefault("logging.include_thoughts", true) 147 - v.SetDefault("logging.include_tool_params", true) 148 - v.SetDefault("logging.include_skill_contents", false) 149 - v.SetDefault("logging.max_thought_chars", 2000) 150 - v.SetDefault("logging.max_json_bytes", 32*1024) 151 - v.SetDefault("logging.max_string_value_chars", 2000) 152 - v.SetDefault("logging.max_skill_content_chars", 8000) 12 + configdefaults.Apply(v) 153 13 }
+33
integration/defaults_test.go
··· 1 + package integration 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/configdefaults" 8 + "github.com/spf13/viper" 9 + ) 10 + 11 + func TestApplyViperDefaultsMatchesSharedDefaults(t *testing.T) { 12 + got := viper.New() 13 + want := viper.New() 14 + 15 + ApplyViperDefaults(got) 16 + configdefaults.Apply(want) 17 + 18 + if gotValue := got.GetString("logging.format"); gotValue != want.GetString("logging.format") { 19 + t.Fatalf("logging.format = %q, want %q", gotValue, want.GetString("logging.format")) 20 + } 21 + if gotValue := got.GetString("console.listen"); gotValue != want.GetString("console.listen") { 22 + t.Fatalf("console.listen = %q, want %q", gotValue, want.GetString("console.listen")) 23 + } 24 + if gotValue := got.GetString("tasks.dir_name"); gotValue != want.GetString("tasks.dir_name") { 25 + t.Fatalf("tasks.dir_name = %q, want %q", gotValue, want.GetString("tasks.dir_name")) 26 + } 27 + if gotValue := got.GetFloat64("telegram.addressing_interject_threshold"); gotValue != want.GetFloat64("telegram.addressing_interject_threshold") { 28 + t.Fatalf("telegram.addressing_interject_threshold = %v, want %v", gotValue, want.GetFloat64("telegram.addressing_interject_threshold")) 29 + } 30 + if gotValue := got.GetStringSlice("multimodal.image.sources"); !reflect.DeepEqual(gotValue, want.GetStringSlice("multimodal.image.sources")) { 31 + t.Fatalf("multimodal.image.sources = %#v, want %#v", gotValue, want.GetStringSlice("multimodal.image.sources")) 32 + } 33 + }
+11
internal/configdefaults/defaults.go
··· 141 141 v.SetDefault("guard.audit.rotate_max_bytes", int64(100*1024*1024)) 142 142 v.SetDefault("guard.approvals.enabled", false) 143 143 144 + v.SetDefault("logging.level", "") 145 + v.SetDefault("logging.format", "text") 146 + v.SetDefault("logging.add_source", false) 147 + v.SetDefault("logging.include_thoughts", true) 148 + v.SetDefault("logging.include_tool_params", true) 149 + v.SetDefault("logging.include_skill_contents", false) 150 + v.SetDefault("logging.max_thought_chars", 2000) 151 + v.SetDefault("logging.max_json_bytes", 32*1024) 152 + v.SetDefault("logging.max_string_value_chars", 2000) 153 + v.SetDefault("logging.max_skill_content_chars", 8000) 154 + 144 155 v.SetDefault("tools.read_file.max_bytes", 256*1024) 145 156 v.SetDefault("tools.read_file.deny_paths", []string{"config.yaml"}) 146 157
+16 -3
internal/toolsutil/plan_register.go
··· 12 12 MaxSteps int 13 13 } 14 14 15 + type planRegisterConfigReader interface { 16 + GetBool(string) bool 17 + GetInt(string) int 18 + IsSet(string) bool 19 + } 20 + 15 21 func BuildPlanCreateRegisterConfig(enabled bool, maxSteps int) PlanCreateRegisterConfig { 16 22 if maxSteps <= 0 { 17 23 maxSteps = 6 ··· 23 29 } 24 30 25 31 func LoadPlanCreateRegisterConfigFromViper() PlanCreateRegisterConfig { 32 + return LoadPlanCreateRegisterConfigFromReader(viper.GetViper()) 33 + } 34 + 35 + func LoadPlanCreateRegisterConfigFromReader(r planRegisterConfigReader) PlanCreateRegisterConfig { 36 + if r == nil { 37 + return BuildPlanCreateRegisterConfig(true, 6) 38 + } 26 39 enabled := true 27 - if viper.IsSet("tools.plan_create.enabled") { 28 - enabled = viper.GetBool("tools.plan_create.enabled") 40 + if r.IsSet("tools.plan_create.enabled") { 41 + enabled = r.GetBool("tools.plan_create.enabled") 29 42 } 30 43 return BuildPlanCreateRegisterConfig( 31 44 enabled, 32 - viper.GetInt("tools.plan_create.max_steps"), 45 + r.GetInt("tools.plan_create.max_steps"), 33 46 ) 34 47 } 35 48
+14 -2
internal/toolsutil/runtime_register.go
··· 5 5 6 6 "github.com/quailyquaily/mistermorph/llm" 7 7 "github.com/quailyquaily/mistermorph/tools" 8 + "github.com/spf13/viper" 8 9 ) 9 10 10 11 type RuntimeToolsRegisterConfig struct { 11 12 PlanCreate PlanCreateRegisterConfig 12 13 TodoUpdate TodoUpdateRegisterConfig 14 + } 15 + 16 + type runtimeRegisterConfigReader interface { 17 + GetBool(string) bool 18 + GetInt(string) int 19 + GetString(string) string 20 + IsSet(string) bool 13 21 } 14 22 15 23 type RuntimeToolLLMOptions struct { ··· 20 28 } 21 29 22 30 func LoadRuntimeToolsRegisterConfigFromViper() RuntimeToolsRegisterConfig { 31 + return LoadRuntimeToolsRegisterConfigFromReader(viper.GetViper()) 32 + } 33 + 34 + func LoadRuntimeToolsRegisterConfigFromReader(r runtimeRegisterConfigReader) RuntimeToolsRegisterConfig { 23 35 return RuntimeToolsRegisterConfig{ 24 - PlanCreate: LoadPlanCreateRegisterConfigFromViper(), 25 - TodoUpdate: LoadTodoUpdateRegisterConfigFromViper(), 36 + PlanCreate: LoadPlanCreateRegisterConfigFromReader(r), 37 + TodoUpdate: LoadTodoUpdateRegisterConfigFromReader(r), 26 38 } 27 39 } 28 40
+15 -3
internal/toolsutil/todo_register.go
··· 19 19 ContactsDir string 20 20 } 21 21 22 + type todoRegisterConfigReader interface { 23 + GetBool(string) bool 24 + GetString(string) string 25 + } 26 + 22 27 func BuildTodoUpdateRegisterConfig(enabled bool, fileStateDir, contactsDirName string) TodoUpdateRegisterConfig { 23 28 fileStateDir = strings.TrimSpace(fileStateDir) 24 29 return TodoUpdateRegisterConfig{ ··· 30 35 } 31 36 32 37 func LoadTodoUpdateRegisterConfigFromViper() TodoUpdateRegisterConfig { 38 + return LoadTodoUpdateRegisterConfigFromReader(viper.GetViper()) 39 + } 40 + 41 + func LoadTodoUpdateRegisterConfigFromReader(r todoRegisterConfigReader) TodoUpdateRegisterConfig { 42 + if r == nil { 43 + return BuildTodoUpdateRegisterConfig(false, "", "") 44 + } 33 45 return BuildTodoUpdateRegisterConfig( 34 - viper.GetBool("tools.todo_update.enabled"), 35 - viper.GetString("file_state_dir"), 36 - viper.GetString("contacts.dir_name"), 46 + r.GetBool("tools.todo_update.enabled"), 47 + r.GetString("file_state_dir"), 48 + r.GetString("contacts.dir_name"), 37 49 ) 38 50 } 39 51