Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

feat: share help and model chat commands

Lyric 98541cf4 f0c819a9

+491 -151
+23
cmd/mistermorph/consolecmd/local_runtime.go
··· 975 975 Ref: "web/console", 976 976 }) 977 977 task := strings.TrimSpace(req.Task) 978 + if output, handled := r.handleConsoleHelpCommand(task); handled { 979 + resp, err := r.submitSyntheticTask(generation, task, output, timeout, strings.TrimSpace(req.TopicID), strings.TrimSpace(req.TopicTitle), trigger) 980 + if err == nil { 981 + releaseGeneration = false 982 + } 983 + return resp, err 984 + } 978 985 if resp, handled, err := r.handleConsoleWorkspaceCommand(generation, req, timeout, trigger); handled { 979 986 if err == nil { 980 987 releaseGeneration = false ··· 1003 1010 releaseGeneration = false 1004 1011 } 1005 1012 return resp, err 1013 + } 1014 + 1015 + func (r *consoleLocalRuntime) handleConsoleHelpCommand(task string) (string, bool) { 1016 + cmdWord, _ := chatcommands.ParseCommand(task) 1017 + if chatcommands.NormalizeCommand(cmdWord) != "/help" { 1018 + return "", false 1019 + } 1020 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{}) 1021 + result, err := chatcommands.HelpHandler(reg, "Available commands:")(context.Background(), "") 1022 + if err != nil { 1023 + return "error: " + strings.TrimSpace(err.Error()), true 1024 + } 1025 + if result == nil { 1026 + return "", true 1027 + } 1028 + return result.Reply, true 1006 1029 } 1007 1030 1008 1031 func (r *consoleLocalRuntime) handleConsoleWorkspaceCommand(generation *consoleLocalRuntimeGeneration, req daemonruntime.SubmitTaskRequest, timeout time.Duration, trigger daemonruntime.TaskTrigger) (daemonruntime.SubmitTaskResponse, bool, error) {
+38
cmd/mistermorph/consolecmd/local_runtime_test.go
··· 665 665 t.Fatalf("final.output = %q, want %q", got, "workspace attached: "+workspaceRoot) 666 666 } 667 667 } 668 + 669 + func TestConsoleLocalRuntimeSubmitTaskHandlesHelpCommand(t *testing.T) { 670 + store, err := daemonruntime.NewConsoleFileStore(daemonruntime.ConsoleFileStoreOptions{ 671 + HeartbeatTopicID: "_heartbeat", 672 + Persist: false, 673 + }) 674 + if err != nil { 675 + t.Fatalf("NewConsoleFileStore() error = %v", err) 676 + } 677 + reader := viper.New() 678 + generation := &consoleLocalRuntimeGeneration{reader: reader} 679 + rt := &consoleLocalRuntime{ 680 + store: store, 681 + generation: generation, 682 + } 683 + 684 + resp, err := rt.submitTask(context.Background(), daemonruntime.SubmitTaskRequest{ 685 + Task: "/help", 686 + }) 687 + if err != nil { 688 + t.Fatalf("submitTask() error = %v", err) 689 + } 690 + if resp.Status != daemonruntime.TaskDone { 691 + t.Fatalf("resp.Status = %q, want %q", resp.Status, daemonruntime.TaskDone) 692 + } 693 + task, ok := store.Get(resp.ID) 694 + if !ok || task == nil { 695 + t.Fatalf("store.Get(%q) missing", resp.ID) 696 + } 697 + result, _ := task.Result.(map[string]any) 698 + final, _ := result["final"].(map[string]any) 699 + output := strings.TrimSpace(fmt.Sprint(final["output"])) 700 + for _, want := range []string{"/help", "/model", "/workspace"} { 701 + if !strings.Contains(output, want) { 702 + t.Fatalf("final.output missing %q: %q", want, output) 703 + } 704 + } 705 + }
+13 -9
cmd/mistermorph/larkcmd/command.go
··· 5 5 "strings" 6 6 7 7 "github.com/quailyquaily/mistermorph/internal/channelopts" 8 + "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 8 9 larkruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/lark" 9 10 "github.com/quailyquaily/mistermorph/internal/configutil" 10 11 "github.com/quailyquaily/mistermorph/internal/toolsutil" ··· 77 78 runtimeToolsConfig toolsutil.RuntimeToolsRegisterConfig, 78 79 ) larkruntime.Dependencies { 79 80 return larkruntime.Dependencies{ 80 - Logger: d.Logger, 81 - LogOptions: d.LogOptions, 82 - ResolveLLMRoute: d.ResolveLLMRoute, 83 - CreateLLMClient: d.CreateLLMClient, 84 - Registry: d.Registry, 85 - RuntimeToolsConfig: runtimeToolsConfig, 86 - Guard: d.Guard, 87 - PromptSpec: d.PromptSpec, 88 - PromptAugment: d.PromptAugment, 81 + CommonDependencies: depsutil.CommonDependencies{ 82 + Logger: d.Logger, 83 + LogOptions: d.LogOptions, 84 + ResolveLLMRoute: d.ResolveLLMRoute, 85 + CreateLLMClient: d.CreateLLMClient, 86 + Registry: d.Registry, 87 + RuntimeToolsConfig: runtimeToolsConfig, 88 + Guard: d.Guard, 89 + PromptSpec: d.PromptSpec, 90 + PromptAugment: d.PromptAugment, 91 + }, 92 + HandleModelCommand: d.HandleModelCommand, 89 93 } 90 94 }
+8 -2
cmd/mistermorph/larkcmd/deps.go
··· 1 1 package larkcmd 2 2 3 - import heartbeatruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/heartbeat" 3 + import ( 4 + heartbeatruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/heartbeat" 5 + larkruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/lark" 6 + ) 4 7 5 8 // Dependencies defines runtime wiring hooks for lark mode. 6 - type Dependencies = heartbeatruntime.Dependencies 9 + type Dependencies struct { 10 + heartbeatruntime.Dependencies 11 + HandleModelCommand larkruntime.HandleModelCommandFunc 12 + }
+13 -9
cmd/mistermorph/linecmd/command.go
··· 5 5 "strings" 6 6 7 7 "github.com/quailyquaily/mistermorph/internal/channelopts" 8 + "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 8 9 lineruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/line" 9 10 "github.com/quailyquaily/mistermorph/internal/configutil" 10 11 "github.com/quailyquaily/mistermorph/internal/toolsutil" ··· 73 74 runtimeToolsConfig toolsutil.RuntimeToolsRegisterConfig, 74 75 ) lineruntime.Dependencies { 75 76 return lineruntime.Dependencies{ 76 - Logger: d.Logger, 77 - LogOptions: d.LogOptions, 78 - ResolveLLMRoute: d.ResolveLLMRoute, 79 - CreateLLMClient: d.CreateLLMClient, 80 - Registry: d.Registry, 81 - RuntimeToolsConfig: runtimeToolsConfig, 82 - Guard: d.Guard, 83 - PromptSpec: d.PromptSpec, 84 - PromptAugment: d.PromptAugment, 77 + CommonDependencies: depsutil.CommonDependencies{ 78 + Logger: d.Logger, 79 + LogOptions: d.LogOptions, 80 + ResolveLLMRoute: d.ResolveLLMRoute, 81 + CreateLLMClient: d.CreateLLMClient, 82 + Registry: d.Registry, 83 + RuntimeToolsConfig: runtimeToolsConfig, 84 + Guard: d.Guard, 85 + PromptSpec: d.PromptSpec, 86 + PromptAugment: d.PromptAugment, 87 + }, 88 + HandleModelCommand: d.HandleModelCommand, 85 89 } 86 90 }
+8 -2
cmd/mistermorph/linecmd/deps.go
··· 1 1 package linecmd 2 2 3 - import heartbeatruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/heartbeat" 3 + import ( 4 + heartbeatruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/heartbeat" 5 + lineruntime "github.com/quailyquaily/mistermorph/internal/channelruntime/line" 6 + ) 4 7 5 8 // Dependencies defines runtime wiring hooks for line mode. 6 - type Dependencies = heartbeatruntime.Dependencies 9 + type Dependencies struct { 10 + heartbeatruntime.Dependencies 11 + HandleModelCommand lineruntime.HandleModelCommandFunc 12 + }
+40 -30
cmd/mistermorph/root.go
··· 154 154 }, 155 155 })) 156 156 cmd.AddCommand(linecmd.NewCommand(linecmd.Dependencies{ 157 - Logger: logutil.LoggerFromViper, 158 - LogOptions: logutil.LogOptionsFromViper, 159 - ResolveLLMRoute: lineLLM.ResolveRoute, 160 - CreateLLMClient: lineLLM.CreateClient, 161 - Registry: registryResolver.Registry, 162 - Guard: guardResolver.Guard, 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 := lineSkills.Config() 165 - if len(stickySkills) > 0 { 166 - cfg.Requested = append(cfg.Requested, stickySkills...) 167 - } 168 - return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 157 + Dependencies: heartbeatruntime.Dependencies{ 158 + Logger: logutil.LoggerFromViper, 159 + LogOptions: logutil.LogOptionsFromViper, 160 + ResolveLLMRoute: lineLLM.ResolveRoute, 161 + CreateLLMClient: lineLLM.CreateClient, 162 + Registry: registryResolver.Registry, 163 + Guard: guardResolver.Guard, 164 + PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 165 + cfg := lineSkills.Config() 166 + if len(stickySkills) > 0 { 167 + cfg.Requested = append(cfg.Requested, stickySkills...) 168 + } 169 + return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 170 + }, 171 + BuildHeartbeatTask: heartbeatutil.BuildHeartbeatTask, 172 + BuildHeartbeatMeta: func(source string, interval time.Duration, checklistPath string, checklistEmpty bool, extra map[string]any) map[string]any { 173 + return heartbeatutil.BuildHeartbeatMeta(source, interval, checklistPath, checklistEmpty, nil, extra) 174 + }, 169 175 }, 170 - BuildHeartbeatTask: heartbeatutil.BuildHeartbeatTask, 171 - BuildHeartbeatMeta: func(source string, interval time.Duration, checklistPath string, checklistEmpty bool, extra map[string]any) map[string]any { 172 - return heartbeatutil.BuildHeartbeatMeta(source, interval, checklistPath, checklistEmpty, nil, extra) 176 + HandleModelCommand: func(text string) (string, bool, error) { 177 + return llmselect.ExecuteCommandText(lineLLM.Values(), llmselect.ProcessStore(), text) 173 178 }, 174 179 })) 175 180 cmd.AddCommand(larkcmd.NewCommand(larkcmd.Dependencies{ 176 - Logger: logutil.LoggerFromViper, 177 - LogOptions: logutil.LogOptionsFromViper, 178 - ResolveLLMRoute: larkLLM.ResolveRoute, 179 - CreateLLMClient: larkLLM.CreateClient, 180 - Registry: registryResolver.Registry, 181 - Guard: guardResolver.Guard, 182 - PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 183 - cfg := larkSkills.Config() 184 - if len(stickySkills) > 0 { 185 - cfg.Requested = append(cfg.Requested, stickySkills...) 186 - } 187 - return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 181 + Dependencies: heartbeatruntime.Dependencies{ 182 + Logger: logutil.LoggerFromViper, 183 + LogOptions: logutil.LogOptionsFromViper, 184 + ResolveLLMRoute: larkLLM.ResolveRoute, 185 + CreateLLMClient: larkLLM.CreateClient, 186 + Registry: registryResolver.Registry, 187 + Guard: guardResolver.Guard, 188 + PromptSpec: func(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, task string, client llm.Client, model string, stickySkills []string) (agent.PromptSpec, []string, error) { 189 + cfg := larkSkills.Config() 190 + if len(stickySkills) > 0 { 191 + cfg.Requested = append(cfg.Requested, stickySkills...) 192 + } 193 + return skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, cfg) 194 + }, 195 + BuildHeartbeatTask: heartbeatutil.BuildHeartbeatTask, 196 + BuildHeartbeatMeta: func(source string, interval time.Duration, checklistPath string, checklistEmpty bool, extra map[string]any) map[string]any { 197 + return heartbeatutil.BuildHeartbeatMeta(source, interval, checklistPath, checklistEmpty, nil, extra) 198 + }, 188 199 }, 189 - BuildHeartbeatTask: heartbeatutil.BuildHeartbeatTask, 190 - BuildHeartbeatMeta: func(source string, interval time.Duration, checklistPath string, checklistEmpty bool, extra map[string]any) map[string]any { 191 - return heartbeatutil.BuildHeartbeatMeta(source, interval, checklistPath, checklistEmpty, nil, extra) 200 + HandleModelCommand: func(text string) (string, bool, error) { 201 + return llmselect.ExecuteCommandText(larkLLM.Values(), llmselect.ProcessStore(), text) 192 202 }, 193 203 })) 194 204 cmd.AddCommand(newToolsCmd(registryResolver.Registry))
+6 -4
docs/feat/feat_20260403_session_llm_profile_switch.md
··· 529 529 530 530 执行结果会影响整个进程,不只是当前 `chat_id`。 531 531 532 - ### 9.2 Slack 532 + ### 9.2 Channel runtimes 533 533 534 - Slack 当前没有单独的 slash command 层;V1 直接把“消息文本以 `/model` 开头”视为控制命令。 534 + Channel runtime 在入队 agent job 前先处理 `/model`。 535 535 536 536 实现落点建议在: 537 537 538 538 - `internal/channelruntime/slack/runtime.go` 539 + - `internal/channelruntime/line/runtime.go` 540 + - `internal/channelruntime/lark/runtime.go` 539 541 540 542 位置上应在 enqueue agent job 之前。 541 543 ··· 573 575 574 576 ## 10) 命令解析建议 575 577 576 - 建议把 `/model` 的解析抽成一层共享 helper,而不是在 Telegram / Slack / Console 各写一遍。 578 + 建议把 `/model` 的解析抽成一层共享 helper,而不是在各 runtime 里重复实现。 577 579 578 580 例如新增: 579 581 ··· 661 663 - profile schema 不变 662 664 - 现有 `llmutil` 继承逻辑复用 663 665 - first-party 与 `integration` 的作用域边界清晰 664 - - Telegram / Slack / Console 都能用统一的 `/model` 语义 666 + - Telegram / Slack / LINE / Lark / Console 都能用统一的 `/model` 语义 665 667 666 668 同时这份设计刻意保持窄范围: 667 669
+39
internal/channelruntime/lark/model_command.go
··· 1 + package lark 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + busruntime "github.com/quailyquaily/mistermorph/internal/bus" 9 + larkbus "github.com/quailyquaily/mistermorph/internal/bus/adapters/lark" 10 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 11 + "github.com/quailyquaily/mistermorph/internal/workspace" 12 + ) 13 + 14 + func maybeHandleLarkCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound larkbus.InboundMessage) (bool, error) { 15 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 16 + ModelCommand: d.HandleModelCommand, 17 + WorkspaceStore: store, 18 + WorkspaceKey: conversationKey, 19 + }) 20 + result, handled, err := reg.Dispatch(ctx, inbound.Text) 21 + if !handled { 22 + return false, nil 23 + } 24 + output := "" 25 + if err != nil { 26 + output = "error: " + strings.TrimSpace(err.Error()) 27 + } else if result != nil { 28 + output = strings.TrimSpace(result.Reply) 29 + } 30 + if output == "" { 31 + return true, nil 32 + } 33 + if ctx == nil { 34 + ctx = context.Background() 35 + } 36 + correlationID := fmt.Sprintf("lark:command:%s:%s", inbound.ChatID, inbound.MessageID) 37 + _, publishErr := publishLarkBusOutbound(ctx, inprocBus, inbound.ChatID, output, inbound.MessageID, correlationID) 38 + return true, publishErr 39 + }
+44
internal/channelruntime/lark/model_command_test.go
··· 1 + package lark 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + 8 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 9 + ) 10 + 11 + func TestLarkCommandRegistryHandlesHelpAndModel(t *testing.T) { 12 + var gotModelText string 13 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 14 + ModelCommand: func(text string) (string, bool, error) { 15 + gotModelText = text 16 + return "model ok", true, nil 17 + }, 18 + WorkspaceKey: "conv", 19 + }) 20 + 21 + help, handled, err := reg.Dispatch(context.Background(), "/help") 22 + if err != nil { 23 + t.Fatalf("/help error = %v", err) 24 + } 25 + if !handled || help == nil { 26 + t.Fatalf("expected /help handled") 27 + } 28 + for _, want := range []string{"/help", "/model", "/workspace"} { 29 + if !strings.Contains(help.Reply, want) { 30 + t.Fatalf("/help reply missing %q: %q", want, help.Reply) 31 + } 32 + } 33 + 34 + model, handled, err := reg.Dispatch(context.Background(), "/model set cheap") 35 + if err != nil { 36 + t.Fatalf("/model error = %v", err) 37 + } 38 + if !handled || model == nil || model.Reply != "model ok" { 39 + t.Fatalf("unexpected /model result: %#v handled=%v", model, handled) 40 + } 41 + if gotModelText != "/model set cheap" { 42 + t.Fatalf("model text = %q, want %q", gotModelText, "/model set cheap") 43 + } 44 + }
+6 -1
internal/channelruntime/lark/run.go
··· 8 8 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 9 9 ) 10 10 11 - type Dependencies = depsutil.CommonDependencies 11 + type HandleModelCommandFunc func(text string) (string, bool, error) 12 + 13 + type Dependencies struct { 14 + depsutil.CommonDependencies 15 + HandleModelCommand HandleModelCommandFunc 16 + } 12 17 13 18 // Hooks is intentionally minimal in the bootstrap phase. 14 19 type Hooks struct{}
+6 -15
internal/channelruntime/lark/runtime.go
··· 15 15 runtimecore "github.com/quailyquaily/mistermorph/internal/channelruntime/core" 16 16 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 17 17 "github.com/quailyquaily/mistermorph/internal/channelruntime/taskruntime" 18 - "github.com/quailyquaily/mistermorph/internal/chatcommands" 19 18 "github.com/quailyquaily/mistermorph/internal/chathistory" 20 19 "github.com/quailyquaily/mistermorph/internal/daemonruntime" 21 20 "github.com/quailyquaily/mistermorph/internal/llminspect" ··· 38 37 return fmt.Errorf("missing lark.app_secret") 39 38 } 40 39 41 - logger, err := depsutil.LoggerFromCommon(d) 40 + logger, err := depsutil.LoggerFromCommon(d.CommonDependencies) 42 41 if err != nil { 43 42 return err 44 43 } ··· 125 124 Model: strings.TrimSpace(route.ClientConfig.Model), 126 125 }) 127 126 } 128 - execRuntime, err := taskruntime.Bootstrap(d, taskruntime.BootstrapOptions{ 127 + execRuntime, err := taskruntime.Bootstrap(d.CommonDependencies, taskruntime.BootstrapOptions{ 129 128 AgentConfig: opts.AgentLimits.ToConfig(), 130 129 EngineToolsConfig: &opts.EngineToolsConfig, 131 130 ClientDecorator: decorateRuntimeClient, ··· 135 134 } 136 135 mainRoute := execRuntime.BootstrapMainRoute 137 136 model := execRuntime.BootstrapMainModel 138 - addressingRoute, err := depsutil.ResolveLLMRouteFromCommon(d, llmutil.RoutePurposeAddressing) 137 + addressingRoute, err := depsutil.ResolveLLMRouteFromCommon(d.CommonDependencies, llmutil.RoutePurposeAddressing) 139 138 if err != nil { 140 139 return err 141 140 } ··· 148 147 } 149 148 addressingClient = decorateRuntimeClient(addressingClient, addressingRoute) 150 149 } 151 - memRuntime, err := runtimecore.NewMemoryRuntime(d, runtimecore.MemoryRuntimeOptions{ 150 + memRuntime, err := runtimecore.NewMemoryRuntime(d.CommonDependencies, runtimecore.MemoryRuntimeOptions{ 152 151 Enabled: opts.MemoryEnabled, 153 152 ShortTermDays: opts.MemoryShortTermDays, 154 153 Logger: logger, ··· 325 324 if text == "" { 326 325 return fmt.Errorf("lark inbound text is required") 327 326 } 328 - cmdWord, cmdArgs := chatcommands.ParseCommand(text) 329 - if chatcommands.NormalizeCommand(cmdWord) == "/workspace" { 330 - result, cmdErr := workspace.ExecuteStoreCommand(workspaceStore, msg.ConversationKey, cmdArgs, nil) 331 - reply := result.Reply 332 - if cmdErr != nil { 333 - reply = "error: " + strings.TrimSpace(cmdErr.Error()) 334 - } 335 - correlationID := fmt.Sprintf("lark:workspace:%s:%s", inbound.ChatID, inbound.MessageID) 336 - _, publishErr := publishLarkBusOutbound(ctx, inprocBus, inbound.ChatID, reply, inbound.MessageID, correlationID) 337 - return publishErr 327 + if handledCommand, cmdErr := maybeHandleLarkCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound); handledCommand { 328 + return cmdErr 338 329 } 339 330 if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "group") { 340 331 mu.Lock()
+39
internal/channelruntime/line/model_command.go
··· 1 + package line 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + busruntime "github.com/quailyquaily/mistermorph/internal/bus" 9 + linebus "github.com/quailyquaily/mistermorph/internal/bus/adapters/line" 10 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 11 + "github.com/quailyquaily/mistermorph/internal/workspace" 12 + ) 13 + 14 + func maybeHandleLineCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, inbound linebus.InboundMessage) (bool, error) { 15 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 16 + ModelCommand: d.HandleModelCommand, 17 + WorkspaceStore: store, 18 + WorkspaceKey: conversationKey, 19 + }) 20 + result, handled, err := reg.Dispatch(ctx, inbound.Text) 21 + if !handled { 22 + return false, nil 23 + } 24 + output := "" 25 + if err != nil { 26 + output = "error: " + strings.TrimSpace(err.Error()) 27 + } else if result != nil { 28 + output = strings.TrimSpace(result.Reply) 29 + } 30 + if output == "" { 31 + return true, nil 32 + } 33 + if ctx == nil { 34 + ctx = context.Background() 35 + } 36 + correlationID := fmt.Sprintf("line:command:%s:%s", inbound.ChatID, inbound.MessageID) 37 + _, publishErr := publishLineBusOutbound(ctx, inprocBus, inbound.ChatID, output, inbound.ReplyToken, correlationID) 38 + return true, publishErr 39 + }
+44
internal/channelruntime/line/model_command_test.go
··· 1 + package line 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + 8 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 9 + ) 10 + 11 + func TestLineCommandRegistryHandlesHelpAndModel(t *testing.T) { 12 + var gotModelText string 13 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 14 + ModelCommand: func(text string) (string, bool, error) { 15 + gotModelText = text 16 + return "model ok", true, nil 17 + }, 18 + WorkspaceKey: "conv", 19 + }) 20 + 21 + help, handled, err := reg.Dispatch(context.Background(), "/help") 22 + if err != nil { 23 + t.Fatalf("/help error = %v", err) 24 + } 25 + if !handled || help == nil { 26 + t.Fatalf("expected /help handled") 27 + } 28 + for _, want := range []string{"/help", "/model", "/workspace"} { 29 + if !strings.Contains(help.Reply, want) { 30 + t.Fatalf("/help reply missing %q: %q", want, help.Reply) 31 + } 32 + } 33 + 34 + model, handled, err := reg.Dispatch(context.Background(), "/model set cheap") 35 + if err != nil { 36 + t.Fatalf("/model error = %v", err) 37 + } 38 + if !handled || model == nil || model.Reply != "model ok" { 39 + t.Fatalf("unexpected /model result: %#v handled=%v", model, handled) 40 + } 41 + if gotModelText != "/model set cheap" { 42 + t.Fatalf("model text = %q, want %q", gotModelText, "/model set cheap") 43 + } 44 + }
+8
internal/channelruntime/line/run.go
··· 5 5 "time" 6 6 7 7 "github.com/quailyquaily/mistermorph/agent" 8 + "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 8 9 ) 10 + 11 + type HandleModelCommandFunc func(text string) (string, bool, error) 12 + 13 + type Dependencies struct { 14 + depsutil.CommonDependencies 15 + HandleModelCommand HandleModelCommandFunc 16 + } 9 17 10 18 // Hooks is intentionally minimal in the bootstrap phase. 11 19 // Runtime callback shapes will be finalized with line runtime implementation.
+6 -17
internal/channelruntime/line/runtime.go
··· 17 17 runtimecore "github.com/quailyquaily/mistermorph/internal/channelruntime/core" 18 18 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 19 19 "github.com/quailyquaily/mistermorph/internal/channelruntime/taskruntime" 20 - "github.com/quailyquaily/mistermorph/internal/chatcommands" 21 20 "github.com/quailyquaily/mistermorph/internal/chathistory" 22 21 "github.com/quailyquaily/mistermorph/internal/daemonruntime" 23 22 "github.com/quailyquaily/mistermorph/internal/llminspect" ··· 30 29 "github.com/quailyquaily/mistermorph/internal/workspace" 31 30 "github.com/quailyquaily/mistermorph/llm" 32 31 ) 33 - 34 - type Dependencies = depsutil.CommonDependencies 35 32 36 33 type lineJob struct { 37 34 TaskID string ··· 66 63 return fmt.Errorf("missing line.channel_secret (set via --line-channel-secret or MISTER_MORPH_LINE_CHANNEL_SECRET)") 67 64 } 68 65 69 - logger, err := depsutil.LoggerFromCommon(d) 66 + logger, err := depsutil.LoggerFromCommon(d.CommonDependencies) 70 67 if err != nil { 71 68 return err 72 69 } ··· 147 144 Model: strings.TrimSpace(route.ClientConfig.Model), 148 145 }) 149 146 } 150 - execRuntime, err := taskruntime.Bootstrap(d, taskruntime.BootstrapOptions{ 147 + execRuntime, err := taskruntime.Bootstrap(d.CommonDependencies, taskruntime.BootstrapOptions{ 151 148 AgentConfig: opts.AgentLimits.ToConfig(), 152 149 EngineToolsConfig: &opts.EngineToolsConfig, 153 150 ClientDecorator: decorateRuntimeClient, ··· 157 154 } 158 155 mainRoute := execRuntime.BootstrapMainRoute 159 156 model := execRuntime.BootstrapMainModel 160 - addressingRoute, err := depsutil.ResolveLLMRouteFromCommon(d, llmutil.RoutePurposeAddressing) 157 + addressingRoute, err := depsutil.ResolveLLMRouteFromCommon(d.CommonDependencies, llmutil.RoutePurposeAddressing) 161 158 if err != nil { 162 159 return err 163 160 } ··· 170 167 } 171 168 addressingClient = decorateRuntimeClient(addressingClient, addressingRoute) 172 169 } 173 - memRuntime, err := runtimecore.NewMemoryRuntime(d, runtimecore.MemoryRuntimeOptions{ 170 + memRuntime, err := runtimecore.NewMemoryRuntime(d.CommonDependencies, runtimecore.MemoryRuntimeOptions{ 174 171 Enabled: opts.MemoryEnabled, 175 172 ShortTermDays: opts.MemoryShortTermDays, 176 173 Logger: logger, ··· 372 369 if text == "" { 373 370 return fmt.Errorf("line inbound text is required") 374 371 } 375 - cmdWord, cmdArgs := chatcommands.ParseCommand(text) 376 - if chatcommands.NormalizeCommand(cmdWord) == "/workspace" { 377 - result, cmdErr := workspace.ExecuteStoreCommand(workspaceStore, msg.ConversationKey, cmdArgs, nil) 378 - reply := result.Reply 379 - if cmdErr != nil { 380 - reply = "error: " + strings.TrimSpace(cmdErr.Error()) 381 - } 382 - correlationID := fmt.Sprintf("line:workspace:%s:%s", inbound.ChatID, inbound.MessageID) 383 - _, publishErr := publishLineBusOutbound(ctx, inprocBus, inbound.ChatID, reply, inbound.ReplyToken, correlationID) 384 - return publishErr 372 + if handledCommand, cmdErr := maybeHandleLineCommand(ctx, d, inprocBus, workspaceStore, msg.ConversationKey, inbound); handledCommand { 373 + return cmdErr 385 374 } 386 375 if strings.EqualFold(strings.TrimSpace(inbound.ChatType), "group") { 387 376 mu.Lock()
+15 -34
internal/channelruntime/slack/model_command.go
··· 10 10 "github.com/quailyquaily/mistermorph/internal/workspace" 11 11 ) 12 12 13 - func maybeHandleSlackProfileCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, event slackInboundEvent, botUserID string) (bool, error) { 13 + func maybeHandleSlackCommand(ctx context.Context, d Dependencies, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, event slackInboundEvent, botUserID string) (bool, error) { 14 14 if isSlackGroupChat(event.ChatType) && !slackCommandExplicitlyAddressed(event.Text, botUserID) { 15 15 return false, nil 16 16 } 17 - if d.HandleModelCommand == nil { 18 - return false, nil 19 - } 20 - output, handled, err := d.HandleModelCommand(normalizeSlackCommandText(event.Text, botUserID)) 17 + text := normalizeSlackCommandText(event.Text, botUserID) 18 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 19 + ModelCommand: d.HandleModelCommand, 20 + WorkspaceStore: store, 21 + WorkspaceKey: conversationKey, 22 + }) 23 + result, handled, err := reg.Dispatch(ctx, text) 21 24 if !handled { 22 25 return false, nil 23 26 } 27 + output := "" 24 28 if err != nil { 25 29 output = "error: " + strings.TrimSpace(err.Error()) 30 + } else if result != nil { 31 + output = strings.TrimSpace(result.Reply) 26 32 } 27 - if ctx == nil { 28 - ctx = context.Background() 29 - } 30 - _, publishErr := publishSlackBusOutbound( 31 - ctx, 32 - inprocBus, 33 - event.TeamID, 34 - event.ChannelID, 35 - output, 36 - event.ThreadTS, 37 - fmt.Sprintf("slack:model:%s:%s", event.ChannelID, event.MessageTS), 38 - ) 39 - return true, publishErr 40 - } 41 - 42 - func maybeHandleSlackWorkspaceCommand(ctx context.Context, inprocBus *busruntime.Inproc, store *workspace.Store, conversationKey string, event slackInboundEvent, botUserID string) (bool, error) { 43 - if isSlackGroupChat(event.ChatType) && !slackCommandExplicitlyAddressed(event.Text, botUserID) { 44 - return false, nil 45 - } 46 - text := normalizeSlackCommandText(event.Text, botUserID) 47 - cmdWord, cmdArgs := chatcommands.ParseCommand(text) 48 - if chatcommands.NormalizeCommand(cmdWord) != "/workspace" { 49 - return false, nil 50 - } 51 - result, err := workspace.ExecuteStoreCommand(store, conversationKey, cmdArgs, nil) 52 - output := result.Reply 53 - if err != nil { 54 - output = "error: " + strings.TrimSpace(err.Error()) 33 + if output == "" { 34 + return true, nil 55 35 } 56 36 if ctx == nil { 57 37 ctx = context.Background() 58 38 } 39 + correlationID := fmt.Sprintf("slack:command:%s:%s", event.ChannelID, event.MessageTS) 59 40 _, publishErr := publishSlackBusOutbound( 60 41 ctx, 61 42 inprocBus, ··· 63 44 event.ChannelID, 64 45 output, 65 46 event.ThreadTS, 66 - fmt.Sprintf("slack:workspace:%s:%s", event.ChannelID, event.MessageTS), 47 + correlationID, 67 48 ) 68 49 return true, publishErr 69 50 }
+44
internal/channelruntime/slack/model_command_test.go
··· 1 + package slack 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + 8 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 9 + ) 10 + 11 + func TestSlackCommandRegistryHandlesHelpAndModel(t *testing.T) { 12 + var gotModelText string 13 + reg := chatcommands.NewRuntimeRegistry(chatcommands.RuntimeRegistryOptions{ 14 + ModelCommand: func(text string) (string, bool, error) { 15 + gotModelText = text 16 + return "model ok", true, nil 17 + }, 18 + WorkspaceKey: "conv", 19 + }) 20 + 21 + help, handled, err := reg.Dispatch(context.Background(), "/help") 22 + if err != nil { 23 + t.Fatalf("/help error = %v", err) 24 + } 25 + if !handled || help == nil { 26 + t.Fatalf("expected /help handled") 27 + } 28 + for _, want := range []string{"/help", "/model", "/workspace"} { 29 + if !strings.Contains(help.Reply, want) { 30 + t.Fatalf("/help reply missing %q: %q", want, help.Reply) 31 + } 32 + } 33 + 34 + model, handled, err := reg.Dispatch(context.Background(), "/model set cheap") 35 + if err != nil { 36 + t.Fatalf("/model error = %v", err) 37 + } 38 + if !handled || model == nil || model.Reply != "model ok" { 39 + t.Fatalf("unexpected /model result: %#v handled=%v", model, handled) 40 + } 41 + if gotModelText != "/model set cheap" { 42 + t.Fatalf("model text = %q, want %q", gotModelText, "/model set cheap") 43 + } 44 + }
+2 -24
internal/channelruntime/slack/runtime.go
··· 868 868 } 869 869 event.Username = username 870 870 event.DisplayName = displayName 871 - handledCommand, cmdErr := maybeHandleSlackWorkspaceCommand(context.Background(), inprocBus, workspaceStore, conversationKey, event, botUserID) 872 - if cmdErr != nil { 873 - logger.Warn("slack_workspace_command_error", 874 - "conversation_key", conversationKey, 875 - "team_id", event.TeamID, 876 - "channel_id", event.ChannelID, 877 - "message_ts", event.MessageTS, 878 - "error", cmdErr.Error(), 879 - ) 880 - callErrorHook(context.Background(), logger, hooks, ErrorEvent{ 881 - Stage: ErrorStagePublishOutbound, 882 - ConversationKey: conversationKey, 883 - TeamID: event.TeamID, 884 - ChannelID: event.ChannelID, 885 - MessageTS: event.MessageTS, 886 - Err: cmdErr, 887 - }) 888 - return nil 889 - } 890 - if handledCommand { 891 - return nil 892 - } 893 - handledCommand, cmdErr = maybeHandleSlackProfileCommand(context.Background(), d, inprocBus, event, botUserID) 871 + handledCommand, cmdErr := maybeHandleSlackCommand(context.Background(), d, inprocBus, workspaceStore, conversationKey, event, botUserID) 894 872 if cmdErr != nil { 895 - logger.Warn("slack_profile_command_error", 873 + logger.Warn("slack_command_error", 896 874 "conversation_key", conversationKey, 897 875 "team_id", event.TeamID, 898 876 "channel_id", event.ChannelID,
+2 -2
internal/chatcommands/dispatcher.go
··· 1 - // Package chatcommands provides a unified slash-command dispatcher that can be 2 - // reused by the TUI chat, Telegram, and Slack runtimes. 1 + // Package chatcommands provides a unified slash-command dispatcher for chat, 2 + // console, and channel runtimes. 3 3 package chatcommands 4 4 5 5 import (
+19
internal/chatcommands/dispatcher_test.go
··· 143 143 t.Fatalf("expected command list in reply: %q", reply) 144 144 } 145 145 } 146 + 147 + func TestModelCommandHandlerRebuildsCommandText(t *testing.T) { 148 + var gotText string 149 + h := ModelCommandHandler(func(text string) (string, bool, error) { 150 + gotText = text 151 + return "ok", true, nil 152 + }) 153 + 154 + res, err := h(context.Background(), "set cheap") 155 + if err != nil { 156 + t.Fatalf("ModelCommandHandler() error = %v", err) 157 + } 158 + if gotText != "/model set cheap" { 159 + t.Fatalf("model command text = %q, want %q", gotText, "/model set cheap") 160 + } 161 + if res == nil || res.Reply != "ok" { 162 + t.Fatalf("unexpected reply: %#v", res) 163 + } 164 + }
+32 -2
internal/chatcommands/handlers.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "strings" 6 7 7 8 "github.com/quailyquaily/mistermorph/internal/llmselect" ··· 33 34 } 34 35 } 35 36 37 + // ModelCommandFunc executes a /model command string and reports whether it was handled. 38 + type ModelCommandFunc = func(text string) (output string, handled bool, err error) 39 + 40 + // ModelCommandHandler adapts a /model command executor to the Registry Handler 41 + // signature, whose input is only the argument tail after "/model". 42 + func ModelCommandHandler(fn ModelCommandFunc) Handler { 43 + return func(ctx context.Context, args string) (*Result, error) { 44 + if fn == nil { 45 + return nil, fmt.Errorf("missing llm profile command handler") 46 + } 47 + output, handled, err := fn(modelCommandText(args)) 48 + if !handled { 49 + return nil, nil 50 + } 51 + if err != nil { 52 + return nil, err 53 + } 54 + return &Result{Reply: output}, nil 55 + } 56 + } 57 + 58 + func modelCommandText(args string) string { 59 + text := "/model" 60 + if args = strings.TrimSpace(args); args != "" { 61 + text += " " + args 62 + } 63 + return text 64 + } 65 + 36 66 // ModelHandler wraps the llmselect package so that /model commands can be 37 67 // handled uniformly across chat front-ends. 38 68 // ··· 65 95 // AsHandler returns the model handler as a standard Handler closure so it can 66 96 // be registered in a Registry. 67 97 func (m *ModelHandler) AsHandler() Handler { 68 - return func(ctx context.Context, text string) (*Result, error) { 69 - return m.Handle(ctx, text) 98 + return func(ctx context.Context, args string) (*Result, error) { 99 + return m.Handle(ctx, modelCommandText(args)) 70 100 } 71 101 }
+36
internal/chatcommands/runtime.go
··· 1 + package chatcommands 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/quailyquaily/mistermorph/internal/workspace" 7 + ) 8 + 9 + type RuntimeRegistryOptions struct { 10 + ModelCommand ModelCommandFunc 11 + WorkspaceStore *workspace.Store 12 + WorkspaceKey string 13 + HelpHeader string 14 + } 15 + 16 + func NewRuntimeRegistry(opts RuntimeRegistryOptions) *Registry { 17 + reg := NewRegistry() 18 + header := opts.HelpHeader 19 + if header == "" { 20 + header = "Available commands:" 21 + } 22 + reg.Register("/help", HelpHandler(reg, header)) 23 + reg.Register("/model", ModelCommandHandler(opts.ModelCommand)) 24 + reg.Register("/workspace", WorkspaceHandler(opts.WorkspaceStore, opts.WorkspaceKey)) 25 + return reg 26 + } 27 + 28 + func WorkspaceHandler(store *workspace.Store, workspaceKey string) Handler { 29 + return func(ctx context.Context, args string) (*Result, error) { 30 + result, err := workspace.ExecuteStoreCommand(store, workspaceKey, args, nil) 31 + if err != nil { 32 + return nil, err 33 + } 34 + return &Result{Reply: result.Reply}, nil 35 + } 36 + }