Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

refactor: simplify llm inspect metadata plumbing

Lyric 300887d5 2cda878b

+512 -293
+1
agent/engine.go
··· 206 206 return e.runLoop(ctx, &engineLoopState{ 207 207 runID: runID, 208 208 model: model, 209 + scene: strings.TrimSpace(opts.Scene), 209 210 log: log, 210 211 messages: messages, 211 212 agentCtx: agentCtx,
+2 -1
agent/engine_helpers.go
··· 42 42 } 43 43 } 44 44 45 - func (e *Engine) forceConclusion(ctx context.Context, messages []llm.Message, model string, agentCtx *Context, extraParams map[string]any, onStream llm.StreamHandler, log *slog.Logger) (*Final, *Context, error) { 45 + func (e *Engine) forceConclusion(ctx context.Context, messages []llm.Message, model string, scene string, agentCtx *Context, extraParams map[string]any, onStream llm.StreamHandler, log *slog.Logger) (*Final, *Context, error) { 46 46 if log == nil { 47 47 log = e.log.With("model", model) 48 48 } ··· 55 55 56 56 result, err := e.client.Chat(ctx, llm.Request{ 57 57 Model: model, 58 + Scene: scene, 58 59 Messages: messages, 59 60 ForceJSON: true, 60 61 Parameters: extraParams,
+25
agent/engine_hooks_test.go
··· 57 57 return false 58 58 } 59 59 60 + func requestScene(calls []llm.Request, callIndex int) string { 61 + if callIndex < 0 || callIndex >= len(calls) { 62 + return "" 63 + } 64 + return calls[callIndex].Scene 65 + } 66 + 60 67 // --- mock tool --- 61 68 62 69 type mockTool struct { ··· 70 77 func (t *mockTool) ParameterSchema() string { return "{}" } 71 78 func (t *mockTool) Execute(_ context.Context, _ map[string]any) (string, error) { 72 79 return t.result, t.err 80 + } 81 + 82 + func TestRun_SetsRequestScene(t *testing.T) { 83 + client := newMockClient(llm.Result{Text: `{"type":"final","output":"ok"}`}) 84 + e := New(client, tools.NewRegistry(), Config{}, DefaultPromptSpec()) 85 + 86 + _, _, err := e.Run(context.Background(), "test", RunOptions{Scene: "cli.loop"}) 87 + if err != nil { 88 + t.Fatalf("Run() error = %v", err) 89 + } 90 + 91 + calls := client.allCalls() 92 + if len(calls) != 1 { 93 + t.Fatalf("calls = %d, want 1", len(calls)) 94 + } 95 + if requestScene(calls, 0) != "cli.loop" { 96 + t.Fatalf("scene = %q, want %q", requestScene(calls, 0), "cli.loop") 97 + } 73 98 } 74 99 75 100 type countingTool struct {
+4 -1
agent/engine_loop.go
··· 17 17 type engineLoopState struct { 18 18 runID string 19 19 model string 20 + scene string 20 21 log *slog.Logger 21 22 22 23 messages []llm.Message ··· 91 92 log.Debug("llm_call_start", "step", step, "messages", len(st.messages)) 92 93 result, err = e.client.Chat(ctx, llm.Request{ 93 94 Model: st.model, 95 + Scene: st.scene, 94 96 Messages: st.messages, 95 97 Tools: st.tools, 96 98 ForceJSON: true, ··· 443 445 } 444 446 } 445 447 446 - return e.forceConclusion(ctx, st.messages, st.model, st.agentCtx, st.extraParams, st.onStream, log) 448 + return e.forceConclusion(ctx, st.messages, st.model, st.scene, st.agentCtx, st.extraParams, st.onStream, log) 447 449 } 448 450 449 451 func (e *Engine) executeToolWithGuard(ctx context.Context, st *engineLoopState, step int, assistantText string, tc *ToolCall, stepStart time.Time, remaining []ToolCall, assistantTextAdded bool) (string, error, *Final, bool) { ··· 476 478 rs := resumeStateV1{ 477 479 RunID: st.runID, 478 480 Model: st.model, 481 + Scene: st.scene, 479 482 Step: step, 480 483 PlanRequired: st.planRequired, 481 484 ParseFailures: st.parseFailures,
+1
agent/engine_resume.go
··· 71 71 return e.runLoop(ctx, &engineLoopState{ 72 72 runID: rs.RunID, 73 73 model: rs.Model, 74 + scene: rs.Scene, 74 75 log: log, 75 76 messages: rs.Messages, 76 77 agentCtx: agentCtx,
+1
agent/engine_runstate.go
··· 12 12 13 13 RunID string `json:"run_id"` 14 14 Model string `json:"model"` 15 + Scene string `json:"scene,omitempty"` 15 16 Step int `json:"step"` 16 17 17 18 PlanRequired bool `json:"plan_required"`
+1
agent/types.go
··· 107 107 108 108 type RunOptions struct { 109 109 Model string 110 + Scene string 110 111 History []llm.Message 111 112 Meta map[string]any 112 113 // CurrentMessage, when set, is appended as the final user turn after meta and history.
+1 -3
cmd/mistermorph/daemoncmd/serve.go
··· 460 460 461 461 func runOneTask(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, client llm.Client, registry *tools.Registry, baseCfg agent.Config, sharedGuard *guard.Guard, task string, model string, meta map[string]any, skillsCfg skillsutil.SkillsConfig) (*agent.Final, *agent.Context, error) { 462 462 ctx = llmstats.WithRunID(ctx, llmstats.NewSyntheticRunID("daemon")) 463 - ctx = llmstats.WithScene(ctx, "daemon.loop") 464 463 skillsCfg.Roots = append([]string(nil), skillsCfg.Roots...) 465 464 skillsCfg.Requested = append([]string(nil), skillsCfg.Requested...) 466 465 promptSpec, _, err := skillsutil.PromptSpecWithSkills(ctx, logger, logOpts, task, client, model, skillsCfg) ··· 480 479 agent.WithLogOptions(logOpts), 481 480 agent.WithGuard(sharedGuard), 482 481 ) 483 - return engine.Run(ctx, task, agent.RunOptions{Model: model, Meta: meta}) 482 + return engine.Run(ctx, task, agent.RunOptions{Model: model, Scene: "daemon.loop", Meta: meta}) 484 483 } 485 484 486 485 func resumeOneTask(ctx context.Context, logger *slog.Logger, logOpts agent.LogOptions, client llm.Client, registry *tools.Registry, baseCfg agent.Config, sharedGuard *guard.Guard, approvalRequestID string) (*agent.Final, *agent.Context, error) { 487 - ctx = llmstats.WithScene(ctx, "daemon.resume") 488 486 promptSpec := agent.DefaultPromptSpec() 489 487 promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 490 488 promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger)
+17 -15
cmd/mistermorph/runcmd/run.go
··· 109 109 } 110 110 slog.SetDefault(logger) 111 111 client := llmstats.WrapRuntimeClient(baseClient, mainCfg.Provider, mainCfg.Endpoint, mainCfg.Model, logger) 112 - mainClient := client 113 112 114 113 logOpts := logutil.LogOptionsFromViper() 115 114 var requestInspector *llminspect.RequestInspector ··· 123 122 return err 124 123 } 125 124 defer func() { _ = requestInspector.Close() }() 126 - if err := llminspect.SetDebugHook(mainClient, requestInspector.Dump); err != nil { 127 - return fmt.Errorf("inspect-request requires uniai provider client") 128 - } 129 125 } 130 126 131 127 if configutil.FlagOrViperBool(cmd, "inspect-prompt", "") { ··· 136 132 return err 137 133 } 138 134 defer func() { _ = promptInspector.Close() }() 139 - client = &llminspect.PromptClient{Base: mainClient, Inspector: promptInspector} 140 135 } 136 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 137 + PromptInspector: promptInspector, 138 + RequestInspector: requestInspector, 139 + APIBase: mainCfg.Endpoint, 140 + Model: mainCfg.Model, 141 + }) 141 142 142 143 reg := (*tools.Registry)(nil) 143 144 if deps.RegistryFromViper != nil { ··· 158 159 return err 159 160 } 160 161 planClient = llmstats.WrapRuntimeClient(planBaseClient, planRoute.ClientConfig.Provider, planRoute.ClientConfig.Endpoint, planRoute.ClientConfig.Model, logger) 161 - if requestInspector != nil { 162 - if err := llminspect.SetDebugHook(planClient, requestInspector.Dump); err != nil { 163 - return fmt.Errorf("inspect-request requires uniai provider client") 164 - } 165 - } 166 - if promptInspector != nil { 167 - planClient = &llminspect.PromptClient{Base: planClient, Inspector: promptInspector} 168 - } 162 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 163 + PromptInspector: promptInspector, 164 + RequestInspector: requestInspector, 165 + APIBase: planRoute.ClientConfig.Endpoint, 166 + Model: planRoute.ClientConfig.Model, 167 + }) 169 168 } 170 169 planModel = strings.TrimSpace(planRoute.ClientConfig.Model) 171 170 toolsutil.RegisterRuntimeTools(reg, toolsutil.LoadRuntimeToolsRegisterConfigFromViper(), toolsutil.RuntimeToolLLMOptions{ ··· 226 225 227 226 runID := llmstats.NewSyntheticRunID("cli") 228 227 ctx = llmstats.WithRunID(ctx, runID) 229 - ctx = llmstats.WithScene(ctx, "cli.loop") 230 - final, runCtx, err := engine.Run(ctx, task, agent.RunOptions{Model: strings.TrimSpace(mainCfg.Model), Meta: runMeta}) 228 + final, runCtx, err := engine.Run(ctx, task, agent.RunOptions{ 229 + Model: strings.TrimSpace(mainCfg.Model), 230 + Scene: "cli.loop", 231 + Meta: runMeta, 232 + }) 231 233 if err != nil { 232 234 if errors.Is(err, errAbortedByUser) { 233 235 return nil
+12 -15
integration/runtime.go
··· 126 126 return nil, err 127 127 } 128 128 model := strings.TrimSpace(mainRoute.ClientConfig.Model) 129 - mainClient := client 130 129 var requestInspector *llminspect.RequestInspector 131 130 var promptInspector *llminspect.PromptInspector 132 131 inspectCleanup := func() error { ··· 153 152 if err != nil { 154 153 return nil, err 155 154 } 156 - if err := llminspect.SetDebugHook(mainClient, requestInspector.Dump); err != nil { 157 - _ = inspectCleanup() 158 - return nil, err 159 - } 160 155 } 161 156 if rt.inspect.Prompt { 162 157 promptInspector, err = llminspect.NewPromptInspector(llminspect.Options{ ··· 169 164 _ = inspectCleanup() 170 165 return nil, err 171 166 } 172 - client = &llminspect.PromptClient{Base: mainClient, Inspector: promptInspector} 173 167 } 168 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 169 + PromptInspector: promptInspector, 170 + RequestInspector: requestInspector, 171 + APIBase: mainRoute.ClientConfig.Endpoint, 172 + Model: model, 173 + }) 174 174 175 175 reg := cloneRegistry(baseReg) 176 176 if reg == nil { ··· 192 192 _ = inspectCleanup() 193 193 return nil, err 194 194 } 195 - if requestInspector != nil { 196 - if err := llminspect.SetDebugHook(planClient, requestInspector.Dump); err != nil { 197 - _ = inspectCleanup() 198 - return nil, err 199 - } 200 - } 201 - if promptInspector != nil { 202 - planClient = &llminspect.PromptClient{Base: planClient, Inspector: promptInspector} 203 - } 195 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 196 + PromptInspector: promptInspector, 197 + RequestInspector: requestInspector, 198 + APIBase: planRoute.ClientConfig.Endpoint, 199 + Model: planRoute.ClientConfig.Model, 200 + }) 204 201 } 205 202 planModel = strings.TrimSpace(planRoute.ClientConfig.Model) 206 203 toolsutil.RegisterRuntimeTools(reg, toolsutil.RuntimeToolsRegisterConfig{
+1
internal/channelruntime/heartbeat/run.go
··· 256 256 ) 257 257 final, _, err := engine.Run(runCtx, task, agent.RunOptions{ 258 258 Model: strings.TrimSpace(opts.Model), 259 + Scene: "heartbeat.loop", 259 260 Meta: opts.Meta, 260 261 }) 261 262 if err != nil {
+18 -29
internal/channelruntime/lark/runtime.go
··· 127 127 model := strings.TrimSpace(mainRoute.ClientConfig.Model) 128 128 addressingModel := strings.TrimSpace(addressingRoute.ClientConfig.Model) 129 129 planModel := strings.TrimSpace(planRoute.ClientConfig.Model) 130 - mainBaseClient := client 131 - addressingBaseClient := addressingClient 132 - planBaseClient := planClient 133 130 var requestInspector *llminspect.RequestInspector 134 131 if opts.InspectRequest { 135 132 requestInspector, err = llminspect.NewRequestInspector(llminspect.Options{ ··· 141 138 return err 142 139 } 143 140 defer func() { _ = requestInspector.Close() }() 144 - if err := llminspect.SetDebugHook(mainBaseClient, requestInspector.Dump); err != nil { 145 - return fmt.Errorf("inspect-request requires uniai provider client") 146 - } 147 - if addressingBaseClient != mainBaseClient { 148 - if err := llminspect.SetDebugHook(addressingBaseClient, requestInspector.Dump); err != nil { 149 - return fmt.Errorf("inspect-request requires uniai provider client") 150 - } 151 - } 152 - if planBaseClient != mainBaseClient && planBaseClient != addressingBaseClient { 153 - if err := llminspect.SetDebugHook(planBaseClient, requestInspector.Dump); err != nil { 154 - return fmt.Errorf("inspect-request requires uniai provider client") 155 - } 156 - } 157 141 } 158 142 var promptInspector *llminspect.PromptInspector 159 143 if opts.InspectPrompt { ··· 166 150 return err 167 151 } 168 152 defer func() { _ = promptInspector.Close() }() 169 - client = &llminspect.PromptClient{Base: mainBaseClient, Inspector: promptInspector} 170 - if addressingBaseClient == mainBaseClient { 171 - addressingClient = client 172 - } else { 173 - addressingClient = &llminspect.PromptClient{Base: addressingBaseClient, Inspector: promptInspector} 174 - } 175 - if planBaseClient == mainBaseClient { 176 - planClient = client 177 - } else if planBaseClient == addressingBaseClient { 178 - planClient = addressingClient 179 - } else { 180 - planClient = &llminspect.PromptClient{Base: planBaseClient, Inspector: promptInspector} 181 - } 182 153 } 154 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 155 + PromptInspector: promptInspector, 156 + RequestInspector: requestInspector, 157 + APIBase: mainRoute.ClientConfig.Endpoint, 158 + Model: model, 159 + }) 160 + addressingClient = llminspect.WrapClient(addressingClient, llminspect.ClientOptions{ 161 + PromptInspector: promptInspector, 162 + RequestInspector: requestInspector, 163 + APIBase: addressingRoute.ClientConfig.Endpoint, 164 + Model: addressingModel, 165 + }) 166 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 167 + PromptInspector: promptInspector, 168 + RequestInspector: requestInspector, 169 + APIBase: planRoute.ClientConfig.Endpoint, 170 + Model: planModel, 171 + }) 183 172 reg := depsutil.RegistryFromCommon(d) 184 173 if reg == nil { 185 174 reg = tools.NewRegistry()
+1 -1
internal/channelruntime/lark/runtime_task.go
··· 61 61 runtimeOpts runtimeTaskOptions, 62 62 ) (*agent.Final, *agent.Context, []string, error) { 63 63 ctx = llmstats.WithMetadata(ctx, job.TaskID, job.EventID) 64 - ctx = llmstats.WithScene(ctx, "lark.loop") 65 64 task := strings.TrimSpace(job.Text) 66 65 if task == "" { 67 66 return nil, nil, nil, fmt.Errorf("empty lark task") ··· 117 116 } 118 117 final, runCtx, err := engine.Run(ctx, task, agent.RunOptions{ 119 118 Model: model, 119 + Scene: "lark.loop", 120 120 History: llmHistory, 121 121 Meta: meta, 122 122 CurrentMessage: currentMsg,
+2 -2
internal/channelruntime/lark/trigger.go
··· 11 11 larkbus "github.com/quailyquaily/mistermorph/internal/bus/adapters/lark" 12 12 "github.com/quailyquaily/mistermorph/internal/chathistory" 13 13 "github.com/quailyquaily/mistermorph/internal/grouptrigger" 14 - "github.com/quailyquaily/mistermorph/internal/llminspect" 15 14 "github.com/quailyquaily/mistermorph/internal/promptprofile" 16 15 "github.com/quailyquaily/mistermorph/llm" 17 16 ) ··· 91 90 if err != nil { 92 91 return grouptrigger.Addressing{}, false, fmt.Errorf("render addressing prompts: %w", err) 93 92 } 94 - return grouptrigger.DecideViaLLM(llminspect.WithModelScene(ctx, "lark.addressing_decision"), grouptrigger.LLMDecisionOptions{ 93 + return grouptrigger.DecideViaLLM(ctx, grouptrigger.LLMDecisionOptions{ 95 94 Client: client, 96 95 Model: model, 96 + Scene: "lark.addressing_decision", 97 97 SystemPrompt: systemPrompt, 98 98 UserPrompt: userPrompt, 99 99 })
+18 -29
internal/channelruntime/line/runtime.go
··· 148 148 model := strings.TrimSpace(mainRoute.ClientConfig.Model) 149 149 addressingModel := strings.TrimSpace(addressingRoute.ClientConfig.Model) 150 150 planModel := strings.TrimSpace(planRoute.ClientConfig.Model) 151 - mainBaseClient := client 152 - addressingBaseClient := addressingClient 153 - planBaseClient := planClient 154 151 var requestInspector *llminspect.RequestInspector 155 152 if opts.InspectRequest { 156 153 requestInspector, err = llminspect.NewRequestInspector(llminspect.Options{ ··· 162 159 return err 163 160 } 164 161 defer func() { _ = requestInspector.Close() }() 165 - if err := llminspect.SetDebugHook(mainBaseClient, requestInspector.Dump); err != nil { 166 - return fmt.Errorf("inspect-request requires uniai provider client") 167 - } 168 - if addressingBaseClient != mainBaseClient { 169 - if err := llminspect.SetDebugHook(addressingBaseClient, requestInspector.Dump); err != nil { 170 - return fmt.Errorf("inspect-request requires uniai provider client") 171 - } 172 - } 173 - if planBaseClient != mainBaseClient && planBaseClient != addressingBaseClient { 174 - if err := llminspect.SetDebugHook(planBaseClient, requestInspector.Dump); err != nil { 175 - return fmt.Errorf("inspect-request requires uniai provider client") 176 - } 177 - } 178 162 } 179 163 var promptInspector *llminspect.PromptInspector 180 164 if opts.InspectPrompt { ··· 187 171 return err 188 172 } 189 173 defer func() { _ = promptInspector.Close() }() 190 - client = &llminspect.PromptClient{Base: mainBaseClient, Inspector: promptInspector} 191 - if addressingBaseClient == mainBaseClient { 192 - addressingClient = client 193 - } else { 194 - addressingClient = &llminspect.PromptClient{Base: addressingBaseClient, Inspector: promptInspector} 195 - } 196 - if planBaseClient == mainBaseClient { 197 - planClient = client 198 - } else if planBaseClient == addressingBaseClient { 199 - planClient = addressingClient 200 - } else { 201 - planClient = &llminspect.PromptClient{Base: planBaseClient, Inspector: promptInspector} 202 - } 203 174 } 175 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 176 + PromptInspector: promptInspector, 177 + RequestInspector: requestInspector, 178 + APIBase: mainRoute.ClientConfig.Endpoint, 179 + Model: model, 180 + }) 181 + addressingClient = llminspect.WrapClient(addressingClient, llminspect.ClientOptions{ 182 + PromptInspector: promptInspector, 183 + RequestInspector: requestInspector, 184 + APIBase: addressingRoute.ClientConfig.Endpoint, 185 + Model: addressingModel, 186 + }) 187 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 188 + PromptInspector: promptInspector, 189 + RequestInspector: requestInspector, 190 + APIBase: planRoute.ClientConfig.Endpoint, 191 + Model: planModel, 192 + }) 204 193 reg := depsutil.RegistryFromCommon(d) 205 194 if reg == nil { 206 195 reg = tools.NewRegistry()
+1 -1
internal/channelruntime/line/runtime_task.go
··· 47 47 runtimeOpts runtimeTaskOptions, 48 48 ) (*agent.Final, *agent.Context, []string, error) { 49 49 ctx = llmstats.WithMetadata(ctx, job.TaskID, job.EventID) 50 - ctx = llmstats.WithScene(ctx, "line.loop") 51 50 task := strings.TrimSpace(job.Text) 52 51 if task == "" { 53 52 return nil, nil, nil, fmt.Errorf("empty line task") ··· 102 101 } 103 102 final, runCtx, err := engine.Run(ctx, task, agent.RunOptions{ 104 103 Model: model, 104 + Scene: "line.loop", 105 105 History: llmHistory, 106 106 Meta: meta, 107 107 CurrentMessage: currentMsg,
+2 -2
internal/channelruntime/line/trigger.go
··· 11 11 linebus "github.com/quailyquaily/mistermorph/internal/bus/adapters/line" 12 12 "github.com/quailyquaily/mistermorph/internal/chathistory" 13 13 "github.com/quailyquaily/mistermorph/internal/grouptrigger" 14 - "github.com/quailyquaily/mistermorph/internal/llminspect" 15 14 "github.com/quailyquaily/mistermorph/internal/promptprofile" 16 15 "github.com/quailyquaily/mistermorph/llm" 17 16 ) ··· 101 100 if err != nil { 102 101 return grouptrigger.Addressing{}, false, fmt.Errorf("render addressing prompts: %w", err) 103 102 } 104 - return grouptrigger.DecideViaLLM(llminspect.WithModelScene(ctx, "line.addressing_decision"), grouptrigger.LLMDecisionOptions{ 103 + return grouptrigger.DecideViaLLM(ctx, grouptrigger.LLMDecisionOptions{ 105 104 Client: client, 106 105 Model: model, 106 + Scene: "line.addressing_decision", 107 107 SystemPrompt: systemPrompt, 108 108 UserPrompt: userPrompt, 109 109 })
+18 -29
internal/channelruntime/slack/runtime.go
··· 255 255 model := strings.TrimSpace(mainRoute.ClientConfig.Model) 256 256 addressingModel := strings.TrimSpace(addressingRoute.ClientConfig.Model) 257 257 planModel := strings.TrimSpace(planRoute.ClientConfig.Model) 258 - mainBaseClient := client 259 - addressingBaseClient := addressingClient 260 - planBaseClient := planClient 261 258 var requestInspector *llminspect.RequestInspector 262 259 if opts.InspectRequest { 263 260 requestInspector, err = llminspect.NewRequestInspector(llminspect.Options{ ··· 269 266 return err 270 267 } 271 268 defer func() { _ = requestInspector.Close() }() 272 - if err := llminspect.SetDebugHook(mainBaseClient, requestInspector.Dump); err != nil { 273 - return fmt.Errorf("inspect-request requires uniai provider client") 274 - } 275 - if addressingBaseClient != mainBaseClient { 276 - if err := llminspect.SetDebugHook(addressingBaseClient, requestInspector.Dump); err != nil { 277 - return fmt.Errorf("inspect-request requires uniai provider client") 278 - } 279 - } 280 - if planBaseClient != mainBaseClient && planBaseClient != addressingBaseClient { 281 - if err := llminspect.SetDebugHook(planBaseClient, requestInspector.Dump); err != nil { 282 - return fmt.Errorf("inspect-request requires uniai provider client") 283 - } 284 - } 285 269 } 286 270 var promptInspector *llminspect.PromptInspector 287 271 if opts.InspectPrompt { ··· 294 278 return err 295 279 } 296 280 defer func() { _ = promptInspector.Close() }() 297 - client = &llminspect.PromptClient{Base: mainBaseClient, Inspector: promptInspector} 298 - if addressingBaseClient == mainBaseClient { 299 - addressingClient = client 300 - } else { 301 - addressingClient = &llminspect.PromptClient{Base: addressingBaseClient, Inspector: promptInspector} 302 - } 303 - if planBaseClient == mainBaseClient { 304 - planClient = client 305 - } else if planBaseClient == addressingBaseClient { 306 - planClient = addressingClient 307 - } else { 308 - planClient = &llminspect.PromptClient{Base: planBaseClient, Inspector: promptInspector} 309 - } 310 281 } 282 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 283 + PromptInspector: promptInspector, 284 + RequestInspector: requestInspector, 285 + APIBase: mainRoute.ClientConfig.Endpoint, 286 + Model: model, 287 + }) 288 + addressingClient = llminspect.WrapClient(addressingClient, llminspect.ClientOptions{ 289 + PromptInspector: promptInspector, 290 + RequestInspector: requestInspector, 291 + APIBase: addressingRoute.ClientConfig.Endpoint, 292 + Model: addressingModel, 293 + }) 294 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 295 + PromptInspector: promptInspector, 296 + RequestInspector: requestInspector, 297 + APIBase: planRoute.ClientConfig.Endpoint, 298 + Model: planModel, 299 + }) 311 300 logOpts := depsutil.LogOptionsFromCommon(d) 312 301 reg := depsutil.RegistryFromCommon(d) 313 302 if reg == nil {
+1 -1
internal/channelruntime/slack/runtime_task.go
··· 54 54 sendSlackText func(context.Context, string, string) error, 55 55 ) (*agent.Final, *agent.Context, []string, *slacktools.Reaction, error) { 56 56 ctx = llmstats.WithRunID(ctx, job.TaskID) 57 - ctx = llmstats.WithScene(ctx, "slack.loop") 58 57 task := strings.TrimSpace(job.Text) 59 58 if task == "" { 60 59 return nil, nil, nil, nil, fmt.Errorf("empty slack task") ··· 158 157 } 159 158 final, runCtx, err := engine.Run(ctx, task, agent.RunOptions{ 160 159 Model: model, 160 + Scene: "slack.loop", 161 161 History: llmHistory, 162 162 Meta: meta, 163 163 CurrentMessage: currentMsg,
+2 -2
internal/channelruntime/slack/trigger.go
··· 10 10 "github.com/quailyquaily/mistermorph/agent" 11 11 "github.com/quailyquaily/mistermorph/internal/chathistory" 12 12 "github.com/quailyquaily/mistermorph/internal/grouptrigger" 13 - "github.com/quailyquaily/mistermorph/internal/llminspect" 14 13 "github.com/quailyquaily/mistermorph/internal/promptprofile" 15 14 "github.com/quailyquaily/mistermorph/llm" 16 15 "github.com/quailyquaily/mistermorph/tools" ··· 92 91 if err != nil { 93 92 return grouptrigger.Addressing{}, false, fmt.Errorf("render addressing prompts: %w", err) 94 93 } 95 - return grouptrigger.DecideViaLLM(llminspect.WithModelScene(ctx, "slack.addressing_decision"), grouptrigger.LLMDecisionOptions{ 94 + return grouptrigger.DecideViaLLM(ctx, grouptrigger.LLMDecisionOptions{ 96 95 Client: client, 97 96 Model: model, 97 + Scene: "slack.addressing_decision", 98 98 SystemPrompt: systemPrompt, 99 99 UserPrompt: userPrompt, 100 100 AddressingTool: addressingTool,
+8 -5
internal/channelruntime/telegram/init_flow.go
··· 12 12 13 13 "github.com/quailyquaily/mistermorph/assets" 14 14 "github.com/quailyquaily/mistermorph/internal/jsonutil" 15 - "github.com/quailyquaily/mistermorph/internal/llminspect" 16 15 "github.com/quailyquaily/mistermorph/internal/statepaths" 17 16 "github.com/quailyquaily/mistermorph/llm" 18 17 ) ··· 140 139 return defaultQuestions, defaultMessage, err 141 140 } 142 141 143 - res, err := client.Chat(llminspect.WithModelScene(ctx, "telegram.init_questions"), llm.Request{ 142 + res, err := client.Chat(ctx, llm.Request{ 144 143 Model: strings.TrimSpace(model), 144 + Scene: "telegram.init_questions", 145 145 ForceJSON: true, 146 146 Messages: []llm.Message{ 147 147 {Role: "system", Content: systemPrompt}, ··· 244 244 return fallbackPostInitGreeting(userAnswer, fallback), err 245 245 } 246 246 247 - res, err := client.Chat(llminspect.WithModelScene(ctx, "telegram.init_post_greeting"), llm.Request{ 247 + res, err := client.Chat(ctx, llm.Request{ 248 248 Model: strings.TrimSpace(model), 249 + Scene: "telegram.init_post_greeting", 249 250 ForceJSON: false, 250 251 Messages: []llm.Message{ 251 252 {Role: "system", Content: systemPrompt}, ··· 321 322 return fallback, nil 322 323 } 323 324 324 - res, err := client.Chat(llminspect.WithModelScene(ctx, "telegram.init_fill"), llm.Request{ 325 + res, err := client.Chat(ctx, llm.Request{ 325 326 Model: strings.TrimSpace(model), 327 + Scene: "telegram.init_fill", 326 328 ForceJSON: true, 327 329 Messages: []llm.Message{ 328 330 {Role: "system", Content: systemPrompt}, ··· 469 471 return original 470 472 } 471 473 472 - res, err := client.Chat(llminspect.WithModelScene(ctx, "telegram.init_soul_polish"), llm.Request{ 474 + res, err := client.Chat(ctx, llm.Request{ 473 475 Model: strings.TrimSpace(model), 476 + Scene: "telegram.init_soul_polish", 474 477 ForceJSON: false, 475 478 Messages: []llm.Message{ 476 479 {Role: "system", Content: systemPrompt},
+2 -2
internal/channelruntime/telegram/memory_flow.go
··· 12 12 "github.com/quailyquaily/mistermorph/internal/channelruntime/depsutil" 13 13 "github.com/quailyquaily/mistermorph/internal/chathistory" 14 14 "github.com/quailyquaily/mistermorph/internal/jsonutil" 15 - "github.com/quailyquaily/mistermorph/internal/llminspect" 16 15 "github.com/quailyquaily/mistermorph/internal/memoryruntime" 17 16 "github.com/quailyquaily/mistermorph/llm" 18 17 "github.com/quailyquaily/mistermorph/memory" ··· 288 287 return memory.SessionDraft{}, fmt.Errorf("render memory draft prompts: %w", err) 289 288 } 290 289 291 - res, err := client.Chat(llminspect.WithModelScene(ctx, "memory.draft"), llm.Request{ 290 + res, err := client.Chat(ctx, llm.Request{ 292 291 Model: model, 292 + Scene: "memory.draft", 293 293 ForceJSON: true, 294 294 Messages: []llm.Message{ 295 295 {Role: "system", Content: sys},
+18 -29
internal/channelruntime/telegram/runtime.go
··· 242 242 model := strings.TrimSpace(mainRoute.ClientConfig.Model) 243 243 addressingModel := strings.TrimSpace(addressingRoute.ClientConfig.Model) 244 244 planModel := strings.TrimSpace(planRoute.ClientConfig.Model) 245 - mainBaseClient := client 246 - addressingBaseClient := addressingClient 247 - planBaseClient := planClient 248 245 var requestInspector *llminspect.RequestInspector 249 246 if opts.InspectRequest { 250 247 requestInspector, err = llminspect.NewRequestInspector(llminspect.Options{ ··· 256 253 return err 257 254 } 258 255 defer func() { _ = requestInspector.Close() }() 259 - if err := llminspect.SetDebugHook(mainBaseClient, requestInspector.Dump); err != nil { 260 - return fmt.Errorf("inspect-request requires uniai provider client") 261 - } 262 - if addressingBaseClient != mainBaseClient { 263 - if err := llminspect.SetDebugHook(addressingBaseClient, requestInspector.Dump); err != nil { 264 - return fmt.Errorf("inspect-request requires uniai provider client") 265 - } 266 - } 267 - if planBaseClient != mainBaseClient && planBaseClient != addressingBaseClient { 268 - if err := llminspect.SetDebugHook(planBaseClient, requestInspector.Dump); err != nil { 269 - return fmt.Errorf("inspect-request requires uniai provider client") 270 - } 271 - } 272 256 } 273 257 var promptInspector *llminspect.PromptInspector 274 258 if opts.InspectPrompt { ··· 281 265 return err 282 266 } 283 267 defer func() { _ = promptInspector.Close() }() 284 - client = &llminspect.PromptClient{Base: mainBaseClient, Inspector: promptInspector} 285 - if addressingBaseClient == mainBaseClient { 286 - addressingClient = client 287 - } else { 288 - addressingClient = &llminspect.PromptClient{Base: addressingBaseClient, Inspector: promptInspector} 289 - } 290 - if planBaseClient == mainBaseClient { 291 - planClient = client 292 - } else if planBaseClient == addressingBaseClient { 293 - planClient = addressingClient 294 - } else { 295 - planClient = &llminspect.PromptClient{Base: planBaseClient, Inspector: promptInspector} 296 - } 297 268 } 269 + client = llminspect.WrapClient(client, llminspect.ClientOptions{ 270 + PromptInspector: promptInspector, 271 + RequestInspector: requestInspector, 272 + APIBase: mainRoute.ClientConfig.Endpoint, 273 + Model: model, 274 + }) 275 + addressingClient = llminspect.WrapClient(addressingClient, llminspect.ClientOptions{ 276 + PromptInspector: promptInspector, 277 + RequestInspector: requestInspector, 278 + APIBase: addressingRoute.ClientConfig.Endpoint, 279 + Model: addressingModel, 280 + }) 281 + planClient = llminspect.WrapClient(planClient, llminspect.ClientOptions{ 282 + PromptInspector: promptInspector, 283 + RequestInspector: requestInspector, 284 + APIBase: planRoute.ClientConfig.Endpoint, 285 + Model: planModel, 286 + }) 298 287 reg := depsutil.RegistryFromCommon(d) 299 288 if reg == nil { 300 289 reg = tools.NewRegistry()
+1 -1
internal/channelruntime/telegram/runtime_task.go
··· 56 56 57 57 func runTelegramTask(ctx context.Context, d Dependencies, logger *slog.Logger, logOpts agent.LogOptions, client llm.Client, baseReg *tools.Registry, api *telegramAPI, filesEnabled bool, fileCacheDir string, filesMaxBytes int64, sharedGuard *guard.Guard, cfg agent.Config, allowedIDs map[int64]bool, job telegramJob, botUsername string, model string, history []chathistory.ChatHistoryItem, historyCap int, stickySkills []string, requestTimeout time.Duration, runtimeOpts runtimeTaskOptions, sendTelegramText func(context.Context, int64, string, string) error) (*agent.Final, *agent.Context, []string, *telegramtools.Reaction, error) { 58 58 ctx = llmstats.WithRunID(ctx, job.TaskID) 59 - ctx = llmstats.WithScene(ctx, "telegram.loop") 60 59 if sendTelegramText == nil { 61 60 return nil, nil, nil, nil, fmt.Errorf("send telegram text callback is required") 62 61 } ··· 179 178 } 180 179 final, agentCtx, err := engine.Run(ctx, task, agent.RunOptions{ 181 180 Model: model, 181 + Scene: "telegram.loop", 182 182 History: llmHistory, 183 183 Meta: meta, 184 184 CurrentMessage: currentMsg,
+2 -2
internal/channelruntime/telegram/trigger.go
··· 11 11 "github.com/quailyquaily/mistermorph/agent" 12 12 "github.com/quailyquaily/mistermorph/internal/chathistory" 13 13 "github.com/quailyquaily/mistermorph/internal/grouptrigger" 14 - "github.com/quailyquaily/mistermorph/internal/llminspect" 15 14 "github.com/quailyquaily/mistermorph/internal/promptprofile" 16 15 "github.com/quailyquaily/mistermorph/llm" 17 16 "github.com/quailyquaily/mistermorph/tools" ··· 168 167 if err != nil { 169 168 return grouptrigger.Addressing{}, false, fmt.Errorf("render addressing prompts: %w", err) 170 169 } 171 - return grouptrigger.DecideViaLLM(llminspect.WithModelScene(ctx, "telegram.addressing_decision"), grouptrigger.LLMDecisionOptions{ 170 + return grouptrigger.DecideViaLLM(ctx, grouptrigger.LLMDecisionOptions{ 172 171 Client: client, 173 172 Model: model, 173 + Scene: "telegram.addressing_decision", 174 174 SystemPrompt: sys, 175 175 UserPrompt: user, 176 176 AddressingTool: addressingTool,
+3
internal/grouptrigger/decision.go
··· 48 48 type LLMDecisionOptions struct { 49 49 Client llm.Client 50 50 Model string 51 + Scene string 51 52 SystemPrompt string 52 53 UserPrompt string 53 54 AddressingTool tools.Tool ··· 182 183 func runAddressingLLMWithoutTool(ctx context.Context, opts LLMDecisionOptions, messages []llm.Message) (addressingLLMOutput, error) { 183 184 res, err := opts.Client.Chat(ctx, llm.Request{ 184 185 Model: opts.Model, 186 + Scene: opts.Scene, 185 187 ForceJSON: true, 186 188 Messages: messages, 187 189 }) ··· 205 207 for round := 0; ; round++ { 206 208 res, err := opts.Client.Chat(ctx, llm.Request{ 207 209 Model: opts.Model, 210 + Scene: opts.Scene, 208 211 ForceJSON: true, 209 212 Messages: messages, 210 213 Tools: llmTools,
+4
internal/grouptrigger/decision_llm_test.go
··· 95 95 got, ok, err := DecideViaLLM(context.Background(), LLMDecisionOptions{ 96 96 Client: client, 97 97 Model: "gpt-5.2", 98 + Scene: "slack.addressing_decision", 98 99 SystemPrompt: "system", 99 100 UserPrompt: "user", 100 101 }) ··· 112 113 } 113 114 if len(client.calls[0].Tools) != 0 { 114 115 t.Fatalf("tools len = %d, want 0", len(client.calls[0].Tools)) 116 + } 117 + if client.calls[0].Scene != "slack.addressing_decision" { 118 + t.Fatalf("scene = %q, want %q", client.calls[0].Scene, "slack.addressing_decision") 115 119 } 116 120 } 117 121
+134 -59
internal/llminspect/inspect.go
··· 32 32 requestCount int 33 33 } 34 34 35 - type modelSceneContextKey struct{} 36 - 37 - const defaultModelScene = "unknown" 35 + const defaultInspectValue = "unknown" 36 + const defaultModelScene = defaultInspectValue 38 37 39 38 //go:embed tmpl/prompt.md 40 39 var promptInspectorTemplateSource string ··· 49 48 50 49 type promptInspectorRequestView struct { 51 50 RequestNumber int 51 + APIBase string 52 + Model string 52 53 Scene string 53 54 Messages []promptInspectorMessageView 54 55 } ··· 61 62 HasToolCalls bool 62 63 ToolCalls string 63 64 Content string 65 + } 66 + 67 + type InspectMetadata struct { 68 + APIBase string 69 + Model string 70 + Scene string 64 71 } 65 72 66 73 func NewPromptInspector(opts Options) (*PromptInspector, error) { ··· 97 104 return p.file.Close() 98 105 } 99 106 100 - func WithModelScene(ctx context.Context, scene string) context.Context { 101 - if ctx == nil { 102 - ctx = context.Background() 103 - } 104 - scene = strings.TrimSpace(scene) 105 - if scene == "" { 106 - scene = defaultModelScene 107 - } 108 - return context.WithValue(ctx, modelSceneContextKey{}, scene) 109 - } 110 - 111 - func ModelSceneFromContext(ctx context.Context) string { 112 - if ctx == nil { 113 - return defaultModelScene 114 - } 115 - if v := ctx.Value(modelSceneContextKey{}); v != nil { 116 - if scene, ok := v.(string); ok { 117 - scene = strings.TrimSpace(scene) 118 - if scene != "" { 119 - return scene 120 - } 121 - } 122 - } 123 - return defaultModelScene 124 - } 125 - 126 - func (p *PromptInspector) Dump(messages []llm.Message) error { 127 - return p.DumpWithScene(defaultModelScene, messages) 128 - } 129 - 130 - func (p *PromptInspector) DumpWithScene(scene string, messages []llm.Message) error { 107 + func (p *PromptInspector) DumpWithMetadata(meta InspectMetadata, messages []llm.Message) error { 131 108 if p == nil || p.file == nil { 132 109 return nil 133 110 } 134 111 p.mu.Lock() 135 112 defer p.mu.Unlock() 136 113 137 - scene = strings.TrimSpace(scene) 138 - if scene == "" { 139 - scene = defaultModelScene 140 - } 114 + meta = normalizeInspectMetadata(meta) 141 115 p.requestCount++ 142 116 143 117 view := promptInspectorRequestView{ 144 118 RequestNumber: p.requestCount, 145 - Scene: scene, 119 + APIBase: meta.APIBase, 120 + Model: meta.Model, 121 + Scene: meta.Scene, 146 122 Messages: make([]promptInspectorMessageView, 0, len(messages)), 147 123 } 148 124 for i, msg := range messages { ··· 202 178 count int 203 179 } 204 180 181 + type RequestEvent struct { 182 + inspector *RequestInspector 183 + number int 184 + meta InspectMetadata 185 + itemCount int 186 + headerWritten bool 187 + } 188 + 205 189 func NewRequestInspector(opts Options) (*RequestInspector, error) { 206 190 startedAt := time.Now() 207 191 dumpDir := strings.TrimSpace(opts.DumpDir) ··· 236 220 return r.file.Close() 237 221 } 238 222 239 - func (r *RequestInspector) Dump(label, payload string) { 223 + func (r *RequestInspector) NewEvent(meta InspectMetadata) *RequestEvent { 240 224 if r == nil || r.file == nil { 241 - return 225 + return nil 242 226 } 243 227 r.mu.Lock() 244 228 defer r.mu.Unlock() 245 229 246 230 r.count++ 231 + return &RequestEvent{ 232 + inspector: r, 233 + number: r.count, 234 + meta: normalizeInspectMetadata(meta), 235 + } 236 + } 237 + 238 + func (e *RequestEvent) Dump(label, payload string) { 239 + if e == nil || e.inspector == nil || e.inspector.file == nil { 240 + return 241 + } 242 + e.inspector.mu.Lock() 243 + defer e.inspector.mu.Unlock() 244 + 245 + e.itemCount++ 247 246 var b strings.Builder 248 - fmt.Fprintf(&b, "\n## Event #%d\n\n", r.count) 249 - fmt.Fprintf(&b, "### %s\n\n", label) 247 + if !e.headerWritten { 248 + fmt.Fprintf(&b, "\n===[ Event #%d ]===========================\n", e.number) 249 + fmt.Fprintf(&b, "api_base: %s\n", e.meta.APIBase) 250 + fmt.Fprintf(&b, "model: %s\n", e.meta.Model) 251 + fmt.Fprintf(&b, "scene: `%s`\n\n", e.meta.Scene) 252 + e.headerWritten = true 253 + } 254 + fmt.Fprintf(&b, "---[ %s #%d-%d ]---------------------------\n", strings.TrimSpace(label), e.number, e.itemCount) 250 255 b.WriteString("```\n") 251 256 b.WriteString(payload) 252 257 if !strings.HasSuffix(payload, "\n") { ··· 254 259 } 255 260 b.WriteString("```\n\n") 256 261 257 - _, _ = r.file.WriteString(b.String()) 258 - _ = r.file.Sync() 262 + _, _ = e.inspector.file.WriteString(b.String()) 263 + _ = e.inspector.file.Sync() 259 264 } 260 265 261 266 func (r *RequestInspector) writeHeader() error { ··· 271 276 return r.file.Sync() 272 277 } 273 278 274 - type PromptClient struct { 275 - Base llm.Client 276 - Inspector *PromptInspector 279 + type ClientOptions struct { 280 + PromptInspector *PromptInspector 281 + RequestInspector *RequestInspector 282 + APIBase string 283 + Model string 277 284 } 278 285 279 - func (c *PromptClient) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { 286 + type Client struct { 287 + Base llm.Client 288 + PromptInspector *PromptInspector 289 + RequestInspector *RequestInspector 290 + APIBase string 291 + Model string 292 + } 293 + 294 + func WrapClient(base llm.Client, opts ClientOptions) llm.Client { 295 + if base == nil { 296 + return nil 297 + } 298 + if opts.PromptInspector == nil && opts.RequestInspector == nil { 299 + return base 300 + } 301 + return &Client{ 302 + Base: base, 303 + PromptInspector: opts.PromptInspector, 304 + RequestInspector: opts.RequestInspector, 305 + APIBase: opts.APIBase, 306 + Model: opts.Model, 307 + } 308 + } 309 + 310 + func (c *Client) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { 280 311 if c == nil || c.Base == nil { 281 312 return llm.Result{}, fmt.Errorf("inspect client is not initialized") 282 313 } 283 - if c.Inspector != nil { 284 - if err := c.Inspector.DumpWithScene(ModelSceneFromContext(ctx), req.Messages); err != nil { 314 + meta := InspectMetadata{ 315 + APIBase: c.APIBase, 316 + Model: firstNonEmpty(req.Model, c.Model), 317 + Scene: req.Scene, 318 + } 319 + if c.PromptInspector != nil { 320 + if err := c.PromptInspector.DumpWithMetadata(meta, req.Messages); err != nil { 285 321 return llm.Result{}, err 286 322 } 287 323 } 324 + if c.RequestInspector != nil { 325 + if event := c.RequestInspector.NewEvent(meta); event != nil { 326 + req.DebugFn = chainDebugFns(req.DebugFn, event.Dump) 327 + } 328 + } 288 329 return c.Base.Chat(ctx, req) 289 330 } 290 331 291 - func SetDebugHook(client llm.Client, dumpFn func(label, payload string)) error { 292 - setter, ok := client.(interface { 293 - SetDebugFn(func(label, payload string)) 294 - }) 295 - if !ok { 296 - return fmt.Errorf("client does not support debug hook") 332 + func normalizeInspectMetadata(meta InspectMetadata) InspectMetadata { 333 + meta.APIBase = strings.TrimSpace(meta.APIBase) 334 + if meta.APIBase == "" { 335 + meta.APIBase = defaultInspectValue 297 336 } 298 - setter.SetDebugFn(dumpFn) 299 - return nil 337 + meta.Model = strings.TrimSpace(meta.Model) 338 + if meta.Model == "" { 339 + meta.Model = defaultInspectValue 340 + } 341 + meta.Scene = strings.TrimSpace(meta.Scene) 342 + if meta.Scene == "" { 343 + meta.Scene = defaultModelScene 344 + } 345 + return meta 346 + } 347 + 348 + func firstNonEmpty(values ...string) string { 349 + for _, raw := range values { 350 + if s := strings.TrimSpace(raw); s != "" { 351 + return s 352 + } 353 + } 354 + return "" 300 355 } 301 356 302 357 func buildFilename(kind string, mode string, t time.Time, tsFormat string) string { ··· 310 365 } 311 366 return fmt.Sprintf("%s_%s_%s.md", kind, mode, ts) 312 367 } 368 + 369 + func chainDebugFns(fns ...func(label, payload string)) func(label, payload string) { 370 + active := make([]func(label, payload string), 0, len(fns)) 371 + for _, fn := range fns { 372 + if fn != nil { 373 + active = append(active, fn) 374 + } 375 + } 376 + if len(active) == 0 { 377 + return nil 378 + } 379 + if len(active) == 1 { 380 + return active[0] 381 + } 382 + return func(label, payload string) { 383 + for _, fn := range active { 384 + fn(label, payload) 385 + } 386 + } 387 + }
+143
internal/llminspect/inspect_test.go
··· 1 + package llminspect 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/quailyquaily/mistermorph/llm" 11 + ) 12 + 13 + func TestPromptInspectorDumpWithMetadataIncludesAPIBaseModelAndScene(t *testing.T) { 14 + dir := t.TempDir() 15 + inspector, err := NewPromptInspector(Options{DumpDir: dir, Mode: "telegram", Task: "demo"}) 16 + if err != nil { 17 + t.Fatalf("NewPromptInspector() error = %v", err) 18 + } 19 + defer func() { _ = inspector.Close() }() 20 + 21 + err = inspector.DumpWithMetadata(InspectMetadata{ 22 + APIBase: "https://api.openai.com/v1", 23 + Model: "gpt-5.2", 24 + Scene: "telegram.loop", 25 + }, []llm.Message{{Role: "user", Content: "hello"}}) 26 + if err != nil { 27 + t.Fatalf("DumpWithMetadata() error = %v", err) 28 + } 29 + 30 + got := readSingleDumpFile(t, dir) 31 + mustContainAll(t, got, 32 + "api_base: https://api.openai.com/v1", 33 + "model: gpt-5.2", 34 + "scene: `telegram.loop`", 35 + ) 36 + } 37 + 38 + func TestRequestInspectorEventDumpIncludesAPIBaseModelAndScene(t *testing.T) { 39 + dir := t.TempDir() 40 + inspector, err := NewRequestInspector(Options{DumpDir: dir, Mode: "telegram", Task: "demo"}) 41 + if err != nil { 42 + t.Fatalf("NewRequestInspector() error = %v", err) 43 + } 44 + defer func() { _ = inspector.Close() }() 45 + 46 + event := inspector.NewEvent(InspectMetadata{ 47 + APIBase: "https://api.openai.com/v1", 48 + Model: "gpt-5.2", 49 + Scene: "telegram.loop", 50 + }) 51 + event.Dump("openai.chat.request", `{"messages":[]}`) 52 + event.Dump("openai.chat.response", `{"id":"resp_1"}`) 53 + 54 + got := readSingleDumpFile(t, dir) 55 + mustContainAll(t, got, 56 + "===[ Event #1 ]===========================", 57 + "api_base: https://api.openai.com/v1", 58 + "model: gpt-5.2", 59 + "scene: `telegram.loop`", 60 + "---[ openai.chat.request #1-1 ]---------------------------", 61 + "---[ openai.chat.response #1-2 ]---------------------------", 62 + ) 63 + } 64 + 65 + func TestWrapClientInjectsRequestScopedDebugFn(t *testing.T) { 66 + dir := t.TempDir() 67 + inspector, err := NewRequestInspector(Options{DumpDir: dir, Mode: "telegram", Task: "demo"}) 68 + if err != nil { 69 + t.Fatalf("NewRequestInspector() error = %v", err) 70 + } 71 + defer func() { _ = inspector.Close() }() 72 + 73 + base := fakeClient{chatFn: func(_ context.Context, req llm.Request) (llm.Result, error) { 74 + if req.DebugFn == nil { 75 + t.Fatalf("expected request-scoped debug callback") 76 + } 77 + req.DebugFn("openai.chat.request", `{"messages":[]}`) 78 + req.DebugFn("openai.chat.response", `{"id":"resp_1"}`) 79 + return llm.Result{}, nil 80 + }} 81 + client := WrapClient(base, ClientOptions{ 82 + RequestInspector: inspector, 83 + APIBase: "https://api.openai.com/v1", 84 + Model: "gpt-5.2", 85 + }) 86 + 87 + called := false 88 + _, err = client.Chat(context.Background(), llm.Request{ 89 + Scene: "telegram.loop", 90 + DebugFn: func(label, payload string) { 91 + called = label == "openai.chat.response" && payload == `{"id":"resp_1"}` 92 + }, 93 + }) 94 + if err != nil { 95 + t.Fatalf("Chat() error = %v", err) 96 + } 97 + if !called { 98 + t.Fatalf("expected original request debug callback to remain active") 99 + } 100 + 101 + got := readSingleDumpFile(t, dir) 102 + mustContainAll(t, got, 103 + "===[ Event #1 ]===========================", 104 + "api_base: https://api.openai.com/v1", 105 + "model: gpt-5.2", 106 + "scene: `telegram.loop`", 107 + "---[ openai.chat.request #1-1 ]---------------------------", 108 + "---[ openai.chat.response #1-2 ]---------------------------", 109 + ) 110 + } 111 + 112 + type fakeClient struct { 113 + chatFn func(ctx context.Context, req llm.Request) (llm.Result, error) 114 + } 115 + 116 + func (f fakeClient) Chat(ctx context.Context, req llm.Request) (llm.Result, error) { 117 + return f.chatFn(ctx, req) 118 + } 119 + 120 + func readSingleDumpFile(t *testing.T, dir string) string { 121 + t.Helper() 122 + entries, err := os.ReadDir(dir) 123 + if err != nil { 124 + t.Fatalf("ReadDir(%q) error = %v", dir, err) 125 + } 126 + if len(entries) != 1 { 127 + t.Fatalf("dump file count = %d, want 1", len(entries)) 128 + } 129 + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) 130 + if err != nil { 131 + t.Fatalf("ReadFile(%q) error = %v", entries[0].Name(), err) 132 + } 133 + return string(data) 134 + } 135 + 136 + func mustContainAll(t *testing.T, text string, parts ...string) { 137 + t.Helper() 138 + for _, part := range parts { 139 + if !strings.Contains(text, part) { 140 + t.Fatalf("output missing %q\nfull output:\n%s", part, text) 141 + } 142 + } 143 + }
+3 -1
internal/llminspect/tmpl/prompt.md
··· 9 9 {{define "request"}} 10 10 11 11 ===[ Request #{{.RequestNumber}} ]=========================== 12 - model_scene: `{{.Scene}}` 12 + api_base: {{.APIBase}} 13 + model: {{.Model}} 14 + scene: `{{.Scene}}` 13 15 14 16 {{- range .Messages}} 15 17 ---[ Message #{{$.RequestNumber}}-{{.Number}} ]---------------------------
+1 -13
internal/llmstats/client.go
··· 8 8 "strings" 9 9 "time" 10 10 11 - "github.com/quailyquaily/mistermorph/internal/llminspect" 12 11 "github.com/quailyquaily/mistermorph/internal/statepaths" 13 12 "github.com/quailyquaily/mistermorph/llm" 14 13 ) ··· 84 83 Provider: c.Provider, 85 84 APIBase: c.APIBase, 86 85 Model: firstNonEmpty(strings.TrimSpace(req.Model), c.DefaultModel), 87 - Scene: llminspect.ModelSceneFromContext(ctx), 86 + Scene: strings.TrimSpace(req.Scene), 88 87 InputTokens: int64(res.Usage.InputTokens), 89 88 OutputTokens: int64(res.Usage.OutputTokens), 90 89 TotalTokens: int64(res.Usage.TotalTokens), ··· 100 99 ) 101 100 } 102 101 return res, nil 103 - } 104 - 105 - func (c *UsageClient) SetDebugFn(fn func(label, payload string)) { 106 - if c == nil || c.Base == nil { 107 - return 108 - } 109 - if setter, ok := c.Base.(interface { 110 - SetDebugFn(func(label, payload string)) 111 - }); ok { 112 - setter.SetDebugFn(fn) 113 - } 114 102 } 115 103 116 104 func (c *UsageClient) Close() error {
+1 -3
internal/llmstats/client_test.go
··· 8 8 "testing" 9 9 "time" 10 10 11 - "github.com/quailyquaily/mistermorph/internal/llminspect" 12 11 "github.com/quailyquaily/mistermorph/llm" 13 12 ) 14 13 ··· 42 41 defer func() { _ = client.Close() }() 43 42 44 43 ctx := WithMetadata(context.Background(), "run_test_1", "evt_test_1") 45 - ctx = llminspect.WithModelScene(ctx, "agent.step") 46 - _, err := client.Chat(ctx, llm.Request{Model: "gpt-5.2"}) 44 + _, err := client.Chat(ctx, llm.Request{Model: "gpt-5.2", Scene: "agent.step"}) 47 45 if err != nil { 48 46 t.Fatalf("Chat() error = %v", err) 49 47 }
-6
internal/llmstats/context.go
··· 3 3 import ( 4 4 "context" 5 5 "strings" 6 - 7 - "github.com/quailyquaily/mistermorph/internal/llminspect" 8 6 ) 9 7 10 8 type runIDContextKey struct{} ··· 61 59 ctx = WithOriginEventID(ctx, originEventID) 62 60 return ctx 63 61 } 64 - 65 - func WithScene(ctx context.Context, scene string) context.Context { 66 - return llminspect.WithModelScene(ctx, scene) 67 - }
+2
llm/llm.go
··· 76 76 77 77 type Request struct { 78 78 Model string 79 + Scene string 79 80 Messages []Message 80 81 Tools []Tool 81 82 ForceJSON bool 82 83 Parameters map[string]any 84 + DebugFn func(label, payload string) 83 85 OnStream StreamHandler 84 86 } 85 87
+14 -19
providers/uniai/client.go
··· 51 51 reasoningBudget *int 52 52 toolsEmulationMode uniaiapi.ToolsEmulationMode 53 53 client *uniaiapi.Client 54 - debugFn func(label, payload string) 55 54 } 56 55 57 56 func New(cfg Config) *Client { ··· 113 112 defer cancel() 114 113 } 115 114 116 - opts := buildChatOptions(req, c.provider, req.ForceJSON, c.toolsEmulationMode, c.temperature, c.reasoningEffort, c.reasoningBudget, c.debugFn, req.OnStream) 115 + opts := buildChatOptions(req, c.provider, req.ForceJSON, c.toolsEmulationMode, c.temperature, c.reasoningEffort, c.reasoningBudget) 117 116 resp, err := c.client.Chat(ctx, opts...) 118 117 if err != nil { 119 - c.emitChatError(err, req.ForceJSON, 1) 118 + c.emitChatError(req.DebugFn, err, req.ForceJSON, 1) 120 119 } 121 120 if err != nil && req.ForceJSON && shouldRetryWithoutResponseFormat(err) { 122 - opts = buildChatOptions(req, c.provider, false, c.toolsEmulationMode, c.temperature, c.reasoningEffort, c.reasoningBudget, c.debugFn, req.OnStream) 121 + opts = buildChatOptions(req, c.provider, false, c.toolsEmulationMode, c.temperature, c.reasoningEffort, c.reasoningBudget) 123 122 resp, err = c.client.Chat(ctx, opts...) 124 123 if err != nil { 125 - c.emitChatError(err, false, 2) 124 + c.emitChatError(req.DebugFn, err, false, 2) 126 125 } 127 126 } 128 127 if err != nil { ··· 130 129 } 131 130 if resp == nil { 132 131 err = fmt.Errorf("uniai: empty response") 133 - c.emitChatError(err, req.ForceJSON, 0) 132 + c.emitChatError(req.DebugFn, err, req.ForceJSON, 0) 134 133 return llm.Result{}, err 135 134 } 136 135 ··· 157 156 return strings.EqualFold(strings.TrimSpace(provider), "gemini") 158 157 } 159 158 160 - func buildChatOptions(req llm.Request, provider string, forceJSON bool, toolsEmulationMode uniaiapi.ToolsEmulationMode, defaultTemperature *float64, defaultReasoningEffort string, defaultReasoningBudget *int, debugFn func(label, payload string), onStream llm.StreamHandler) []uniaiapi.ChatOption { 159 + func buildChatOptions(req llm.Request, provider string, forceJSON bool, toolsEmulationMode uniaiapi.ToolsEmulationMode, defaultTemperature *float64, defaultReasoningEffort string, defaultReasoningBudget *int) []uniaiapi.ChatOption { 161 160 msgs := make([]uniaiapi.Message, len(req.Messages)) 162 161 for i, m := range req.Messages { 163 162 msg := uniaiapi.Message{Role: m.Role, Content: m.Content} ··· 244 243 })) 245 244 } 246 245 247 - if debugFn != nil { 248 - opts = append(opts, uniaiapi.WithDebugFn(debugFn)) 246 + if req.DebugFn != nil { 247 + opts = append(opts, uniaiapi.WithDebugFn(req.DebugFn)) 249 248 } 250 - if onStream != nil { 249 + if req.OnStream != nil { 251 250 opts = append(opts, uniaiapi.WithOnStream(func(ev uniaiapi.StreamEvent) error { 252 251 streamEvent := llm.StreamEvent{ 253 252 Delta: ev.Delta, ··· 268 267 TotalTokens: ev.Usage.TotalTokens, 269 268 } 270 269 } 271 - return onStream(streamEvent) 270 + return req.OnStream(streamEvent) 272 271 })) 273 272 } 274 273 ··· 302 301 } 303 302 } 304 303 305 - func (c *Client) SetDebugFn(fn func(label, payload string)) { 306 - c.debugFn = fn 307 - } 308 - 309 - func (c *Client) emitChatError(err error, forceJSON bool, attempt int) { 310 - if err == nil || c == nil || c.debugFn == nil { 304 + func (c *Client) emitChatError(debugFn func(label, payload string), err error, forceJSON bool, attempt int) { 305 + if err == nil || c == nil || debugFn == nil { 311 306 return 312 307 } 313 308 ··· 330 325 331 326 data, marshalErr := json.Marshal(payload) 332 327 if marshalErr != nil { 333 - c.debugFn(label, err.Error()) 328 + debugFn(label, err.Error()) 334 329 return 335 330 } 336 - c.debugFn(label, string(data)) 331 + debugFn(label, string(data)) 337 332 } 338 333 339 334 func toLLMToolCalls(calls []uniaiapi.ToolCall) []llm.ToolCall {
+47 -22
providers/uniai/client_test.go
··· 17 17 18 18 opts := append( 19 19 []uniaiapi.ChatOption{uniaiapi.WithMessages(uniaiapi.User("old"))}, 20 - buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil, nil, nil)..., 20 + buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil)..., 21 21 ) 22 22 23 23 built, err := uniaichat.BuildRequest(opts...) ··· 44 44 }, 45 45 } 46 46 47 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil, nil, nil) 47 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 48 48 built, err := uniaichat.BuildRequest(opts...) 49 49 if err != nil { 50 50 t.Fatalf("build request: %v", err) ··· 70 70 }, 71 71 } 72 72 73 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil, nil, nil) 73 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 74 74 built, err := uniaichat.BuildRequest(opts...) 75 75 if err != nil { 76 76 t.Fatalf("build request: %v", err) ··· 93 93 called := false 94 94 req := llm.Request{ 95 95 Messages: []llm.Message{{Role: "user", Content: "hello"}}, 96 + OnStream: func(ev llm.StreamEvent) error { 97 + called = true 98 + if ev.Delta != "abc" { 99 + t.Fatalf("delta = %q, want abc", ev.Delta) 100 + } 101 + if ev.ToolCallDelta == nil || ev.ToolCallDelta.Name != "message_react" { 102 + t.Fatalf("tool_call_delta = %#v", ev.ToolCallDelta) 103 + } 104 + if !ev.Done { 105 + t.Fatalf("done = false, want true") 106 + } 107 + if ev.Usage == nil || ev.Usage.TotalTokens != 9 { 108 + t.Fatalf("usage = %#v", ev.Usage) 109 + } 110 + return nil 111 + }, 96 112 } 97 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil, nil, func(ev llm.StreamEvent) error { 98 - called = true 99 - if ev.Delta != "abc" { 100 - t.Fatalf("delta = %q, want abc", ev.Delta) 101 - } 102 - if ev.ToolCallDelta == nil || ev.ToolCallDelta.Name != "message_react" { 103 - t.Fatalf("tool_call_delta = %#v", ev.ToolCallDelta) 104 - } 105 - if !ev.Done { 106 - t.Fatalf("done = false, want true") 107 - } 108 - if ev.Usage == nil || ev.Usage.TotalTokens != 9 { 109 - t.Fatalf("usage = %#v", ev.Usage) 110 - } 111 - return nil 112 - }) 113 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 113 114 114 115 built, err := uniaichat.BuildRequest(opts...) 115 116 if err != nil { ··· 140 141 } 141 142 } 142 143 144 + func TestBuildChatOptionsMapsDebugFn(t *testing.T) { 145 + var gotLabel, gotPayload string 146 + req := llm.Request{ 147 + Messages: []llm.Message{{Role: "user", Content: "hello"}}, 148 + DebugFn: func(label, payload string) { 149 + gotLabel = label 150 + gotPayload = payload 151 + }, 152 + } 153 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 154 + 155 + built, err := uniaichat.BuildRequest(opts...) 156 + if err != nil { 157 + t.Fatalf("build request: %v", err) 158 + } 159 + if built.Options.DebugFn == nil { 160 + t.Fatalf("expected debug callback") 161 + } 162 + built.Options.DebugFn("openai.chat.request", `{"messages":[]}`) 163 + if gotLabel != "openai.chat.request" || gotPayload != `{"messages":[]}` { 164 + t.Fatalf("debug callback mismatch: label=%q payload=%q", gotLabel, gotPayload) 165 + } 166 + } 167 + 143 168 func TestBuildChatOptionsDoesNotInjectTemperatureWhenUnset(t *testing.T) { 144 169 req := llm.Request{ 145 170 Messages: []llm.Message{{Role: "user", Content: "hello"}}, 146 171 } 147 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil, nil, nil) 172 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, nil, "", nil) 148 173 built, err := uniaichat.BuildRequest(opts...) 149 174 if err != nil { 150 175 t.Fatalf("build request: %v", err) ··· 160 185 } 161 186 temperature := 0.4 162 187 reasoningBudget := 8192 163 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, &temperature, "high", &reasoningBudget, nil, nil) 188 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, &temperature, "high", &reasoningBudget) 164 189 built, err := uniaichat.BuildRequest(opts...) 165 190 if err != nil { 166 191 t.Fatalf("build request: %v", err) ··· 182 207 Parameters: map[string]any{"temperature": 0.1}, 183 208 } 184 209 temperature := 0.4 185 - opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, &temperature, "", nil, nil, nil) 210 + opts := buildChatOptions(req, "", false, uniaiapi.ToolsEmulationOff, &temperature, "", nil) 186 211 built, err := uniaichat.BuildRequest(opts...) 187 212 if err != nil { 188 213 t.Fatalf("build request: %v", err)