From c5e46786d1a8738497915fe0c391f01cf81c4862 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Fri, 5 Jun 2026 09:38:43 +0000 Subject: [PATCH] feat(config): reasoning_effort to disable thinking on any reasoning model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a global `reasoning_effort` config (and Session plumbing) sent as the OpenAI-standard "reasoning_effort" field on every request, via cogito's new OpenAIOptions.ReasoningEffort. Why: `metadata.enable_thinking:false` only silences models whose chat template honors that flag. Reasoning models whose GGUF template has no enable_thinking toggle (e.g. LFM2.5) ignore it and keep emitting blocks — but they DO honor `reasoning_effort:"none"`. This gives a reliable, model-agnostic way to turn thinking off. Empty leaves it unset (no behavior change). - types.Config.ReasoningEffort (yaml: reasoning_effort), wired into the main session and the per-agent LLM factory. - bumps cogito to the commit adding OpenAIOptions.ReasoningEffort. - test: the configured reasoning_effort reaches the wire; README documented. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 ++ chat/session.go | 68 +++++++++++---------- chat/session_reasoning_test.go | 108 +++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 2 + types/config.go | 16 +++-- 6 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 chat/session_reasoning_test.go diff --git a/README.md b/README.md index b39cde7..fcf9a58 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,12 @@ prompt: | metadata: enable_thinking: "false" +# Optional: OpenAI-standard reasoning effort, sent on every request as +# "reasoning_effort" ("none"/"low"/"medium"/"high"). Unlike metadata.enable_thinking, +# this works even when the model's chat template has no enable_thinking toggle +# (e.g. LFM2.5) — so it's the reliable way to turn a reasoning model's thinking off: +reasoning_effort: "none" + # Optional: agent behavior agent_options: iterations: 10 diff --git a/chat/session.go b/chat/session.go index b9f5db0..84e4746 100644 --- a/chat/session.go +++ b/chat/session.go @@ -53,14 +53,15 @@ type Session struct { agentMu sync.Mutex agentStart map[string]time.Time // sub-agent ID -> spawn time, for elapsed - agentManager *cogito.AgentManager - agentDefs []cogito.AgentDefinition - agentModels map[string]bool // models configured per agent type (for the LLM-model guard) - agentLogs *agentLogStore // per-sub-agent activity log (for the agent_logs tool) - llmModel string - apiKey string - baseURL string - metadata map[string]string // global per-request metadata; merged with per-agent overrides + agentManager *cogito.AgentManager + agentDefs []cogito.AgentDefinition + agentModels map[string]bool // models configured per agent type (for the LLM-model guard) + agentLogs *agentLogStore // per-sub-agent activity log (for the agent_logs tool) + llmModel string + apiKey string + baseURL string + metadata map[string]string // global per-request metadata; merged with per-agent overrides + reasoningEffort string // OpenAI reasoning_effort sent on every request (e.g. "none") configurator *manage.Configurator reloadMu sync.Mutex @@ -151,7 +152,8 @@ func CommandTransport(cmd string, args []string, env ...string) mcp.Transport { // NewSession creates a new chat session func NewSession(ctx context.Context, cfg types.Config, callbacks Callbacks, transports ...mcp.Transport) (*Session, error) { var llm cogito.LLM = clients.NewOpenAILLMWithOptions(cfg.Model, cfg.APIKey, cfg.BaseURL, clients.OpenAIOptions{ - Metadata: cfg.Metadata, + Metadata: cfg.Metadata, + ReasoningEffort: cfg.ReasoningEffort, }) // Session tracing: wrap the LLM so every call is appended to the transcript. @@ -185,27 +187,28 @@ func NewSession(ctx context.Context, cfg types.Config, callbacks Callbacks, tran } s := &Session{ - ctx: ctx, - llm: llm, - clients: clients, - fragment: cogito.NewEmptyFragment(), - messages: []openai.ChatCompletionMessage{}, - callbacks: callbacks, - cogitoOptions: cfg.AgentOptions, - compaction: cfg.Compaction, - allowedTools: make(map[string]bool), - agentStart: make(map[string]time.Time), - agentManager: agentManager, - agentLogs: newAgentLogStore(), - llmModel: cfg.Model, - apiKey: cfg.APIKey, - baseURL: cfg.BaseURL, - metadata: cfg.Metadata, - mcpClient: client, - cfgClients: map[string]*mcp.ClientSession{}, - cfgServers: map[string]types.MCPServer{}, - configurator: manage.New(plugin.BaseDir(), config.WritablePath()), - tracer: tracer, + ctx: ctx, + llm: llm, + clients: clients, + fragment: cogito.NewEmptyFragment(), + messages: []openai.ChatCompletionMessage{}, + callbacks: callbacks, + cogitoOptions: cfg.AgentOptions, + compaction: cfg.Compaction, + allowedTools: make(map[string]bool), + agentStart: make(map[string]time.Time), + agentManager: agentManager, + agentLogs: newAgentLogStore(), + llmModel: cfg.Model, + apiKey: cfg.APIKey, + baseURL: cfg.BaseURL, + metadata: cfg.Metadata, + reasoningEffort: cfg.ReasoningEffort, + mcpClient: client, + cfgClients: map[string]*mcp.ClientSession{}, + cfgServers: map[string]types.MCPServer{}, + configurator: manage.New(plugin.BaseDir(), config.WritablePath()), + tracer: tracer, } for _, name := range cfg.AllowedTools { s.allowedTools[name] = true @@ -492,8 +495,9 @@ func (s *Session) SendMessage(text string) (string, error) { // metadata is this agent type's override; overlay it on the global // session metadata (per-key: agent wins, global-only keys inherited). return clients.NewOpenAILLMWithOptions(chosen, s.apiKey, s.baseURL, clients.OpenAIOptions{ - Temperature: temperature, - Metadata: mergeMetadata(s.metadata, metadata), + Temperature: temperature, + Metadata: mergeMetadata(s.metadata, metadata), + ReasoningEffort: s.reasoningEffort, }) }), cogito.WithAgentSpawnCallback(func(a *cogito.AgentState) { diff --git a/chat/session_reasoning_test.go b/chat/session_reasoning_test.go new file mode 100644 index 0000000..9852745 --- /dev/null +++ b/chat/session_reasoning_test.go @@ -0,0 +1,108 @@ +package chat_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/mudler/nib/chat" + "github.com/mudler/nib/types" + "github.com/mudler/xlog" +) + +// reasoningEffortCapturingOpenAI records the "reasoning_effort" of every request +// and replies with a single stop message (streaming + non-streaming paths). +func reasoningEffortCapturingOpenAI(record func(string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + Stream bool `json:"stream"` + ReasoningEffort string `json:"reasoning_effort"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &req) + record(req.ReasoningEffort) + + if !req.Stream { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "fake", "object": "chat.completion", "model": "fake", + "choices": []any{map[string]any{ + "index": 0, "message": map[string]any{"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }}, + }) + return + } + w.Header().Set("Content-Type", "text/event-stream") + fl, _ := w.(http.Flusher) + emit := func(delta map[string]any, finish string) { + choice := map[string]any{"index": 0, "delta": delta} + if finish != "" { + choice["finish_reason"] = finish + } + b, _ := json.Marshal(map[string]any{ + "id": "fake", "object": "chat.completion.chunk", "model": "fake", + "choices": []any{choice}, + }) + w.Write([]byte("data: ")) + w.Write(b) + w.Write([]byte("\n\n")) + if fl != nil { + fl.Flush() + } + } + emit(map[string]any{"content": "ok"}, "") + emit(map[string]any{}, "stop") + w.Write([]byte("data: [DONE]\n\n")) + if fl != nil { + fl.Flush() + } + } +} + +// TestSessionSendsConfiguredReasoningEffort proves Config.ReasoningEffort reaches +// the wire as the OpenAI "reasoning_effort" field — the lever that disables a +// reasoning model's thinking when its template has no enable_thinking toggle. +func TestSessionSendsConfiguredReasoningEffort(t *testing.T) { + xlog.SetLogger(xlog.NewLogger(xlog.LogLevel("error"), "")) + + var mu sync.Mutex + var last string + srv := httptest.NewServer(reasoningEffortCapturingOpenAI(func(s string) { + mu.Lock() + last = s + mu.Unlock() + })) + defer srv.Close() + + cfg := types.Config{ + Model: "fake-model", + APIKey: "fake-key", + BaseURL: srv.URL + "/v1", + LogLevel: "error", + ApprovalMode: "auto", + AgentOptions: types.AgentOptions{Iterations: 10, MaxAttempts: 3, MaxRetries: 3}, + ReasoningEffort: "none", + } + + session, err := chat.NewSession(context.Background(), cfg, chat.Callbacks{}) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + defer session.Close() + + if _, err := session.SendMessage("hi"); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + mu.Lock() + got := last + mu.Unlock() + if got != "none" { + t.Fatalf("request reasoning_effort = %q, want none", got) + } +} diff --git a/go.mod b/go.mod index f472004..64f2095 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/modelcontextprotocol/go-sdk v1.0.0 - github.com/mudler/cogito v0.10.1-0.20260604212312-77024e447c46 + github.com/mudler/cogito v0.10.1-0.20260605093203-0563a9999d90 github.com/mudler/xlog v0.0.1 github.com/sashabaranov/go-openai v1.41.2 golang.org/x/term v0.36.0 diff --git a/go.sum b/go.sum index d832a7b..1540405 100644 --- a/go.sum +++ b/go.sum @@ -148,6 +148,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mudler/cogito v0.10.1-0.20260604212312-77024e447c46 h1:lZGsJnVZSXE6J8BJq4frtmpFU+JYc4zBzP5YCULGZYU= github.com/mudler/cogito v0.10.1-0.20260604212312-77024e447c46/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4= +github.com/mudler/cogito v0.10.1-0.20260605093203-0563a9999d90 h1:GKLjvHS0yrhTdVoZw8Zu8s2mo0hA6/V72Gto+wDNZ+I= +github.com/mudler/cogito v0.10.1-0.20260605093203-0563a9999d90/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4= github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg= github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= diff --git a/types/config.go b/types/config.go index a04ce51..69814e9 100644 --- a/types/config.go +++ b/types/config.go @@ -93,11 +93,17 @@ type Config struct { // LocalAI use it for per-request flags, e.g. {"enable_thinking": "false"} // to disable reasoning. Applied to the main session and inherited by // sub-agents (see AgentTypeConfig.Metadata for per-agent overrides). - Metadata map[string]string `yaml:"metadata,omitempty"` - MCPServers map[string]MCPServer `yaml:"mcp_servers"` - AgentOptions AgentOptions `yaml:"agent_options"` - Compaction CompactionConfig `yaml:"compaction"` - Agents []AgentTypeConfig `yaml:"agents"` + Metadata map[string]string `yaml:"metadata,omitempty"` + // ReasoningEffort sets the OpenAI "reasoning_effort" on every request + // ("none"/"low"/"medium"/"high"). Unlike Metadata.enable_thinking, this binds + // even when the model's chat template has no enable_thinking toggle (e.g. + // LFM2.5), so it's the reliable way to disable a reasoning model's thinking + // ("none"). Empty leaves the field unset. + ReasoningEffort string `yaml:"reasoning_effort,omitempty"` + MCPServers map[string]MCPServer `yaml:"mcp_servers"` + AgentOptions AgentOptions `yaml:"agent_options"` + Compaction CompactionConfig `yaml:"compaction"` + Agents []AgentTypeConfig `yaml:"agents"` PromptFragments []string `yaml:"prompt_fragments"` Skills []Skill `yaml:"skills"`