From 552ddc059e5b32b9e996efc211a39f72cd0bf174 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sat, 28 Feb 2026 15:45:34 +0700 Subject: [PATCH] feat(thinking): add per-agent extended thinking with Anthropic, OpenAI, and DashScope support Implement full thinking mode system: per-agent thinking_level config (off/low/medium/high) stored in other_config JSONB, per-provider param injection (Anthropic budget_tokens, OpenAI reasoning_effort, DashScope enable_thinking+thinking_budget), Anthropic extended thinking with streaming parse and tool use block preservation via RawAssistantContent, thinking token tracking in trace spans, and Web UI with agent config selector, chat thinking block rendering, and trace thinking token display. No DB migration needed. Co-Authored-By: Claude Opus 4.6 --- internal/agent/loop.go | 22 ++- internal/agent/loop_tracing.go | 21 +- internal/agent/resolver.go | 1 + internal/providers/anthropic.go | 181 +++++++++++++++++- internal/providers/dashscope.go | 27 +++ internal/providers/openai.go | 23 ++- internal/providers/openai_types.go | 13 +- internal/providers/types.go | 24 ++- internal/store/agent_store.go | 15 ++ ui/web/src/components/chat/thinking-block.tsx | 41 ++++ .../agents/agent-detail/agent-config-tab.tsx | 20 +- .../agent-detail/config-sections/index.ts | 1 + .../config-sections/thinking-section.tsx | 54 ++++++ ui/web/src/pages/chat/chat-page.tsx | 2 + ui/web/src/pages/chat/chat-thread.tsx | 14 +- .../src/pages/chat/hooks/use-chat-messages.ts | 17 ++ ui/web/src/pages/traces/hooks/use-traces.ts | 2 +- .../src/pages/traces/trace-detail-dialog.tsx | 10 + 18 files changed, 455 insertions(+), 33 deletions(-) create mode 100644 ui/web/src/components/chat/thinking-block.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/thinking-section.tsx diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 6b574bb8..ad6413aa 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -100,6 +100,9 @@ type Loop struct { // Global builtin tool settings (from builtin_tools table, managed mode) builtinToolSettings tools.BuiltinToolSettings + + // Thinking level for extended thinking support + thinkingLevel string } // AgentEvent is emitted during agent execution for WS broadcasting. @@ -162,6 +165,9 @@ type LoopConfig struct { // Global builtin tool settings (from builtin_tools table, managed mode) BuiltinToolSettings tools.BuiltinToolSettings + + // Thinking level: "off", "low", "medium", "high" (from agent other_config) + ThinkingLevel string } func NewLoop(cfg LoopConfig) *Loop { @@ -220,6 +226,7 @@ func NewLoop(cfg LoopConfig) *Loop { injectionAction: action, maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, + thinkingLevel: cfg.ThinkingLevel, } } @@ -540,10 +547,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) Tools: toolDefs, Model: l.model, Options: map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, + providers.OptMaxTokens: 8192, + providers.OptTemperature: 0.7, }, } + if l.thinkingLevel != "" && l.thinkingLevel != "off" { + chatReq.Options[providers.OptThinkingLevel] = l.thinkingLevel + } // Call LLM (streaming or non-streaming) var resp *providers.ChatResponse @@ -585,6 +595,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) totalUsage.PromptTokens += resp.Usage.PromptTokens totalUsage.CompletionTokens += resp.Usage.CompletionTokens totalUsage.TotalTokens += resp.Usage.TotalTokens + totalUsage.ThinkingTokens += resp.Usage.ThinkingTokens } // No tool calls → done @@ -595,9 +606,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Build assistant message with tool calls assistantMsg := providers.Message{ - Role: "assistant", - Content: resp.Content, - ToolCalls: resp.ToolCalls, + Role: "assistant", + Content: resp.Content, + ToolCalls: resp.ToolCalls, + RawAssistantContent: resp.RawAssistantContent, // preserve thinking blocks for Anthropic passback } messages = append(messages, assistantMsg) pendingMsgs = append(pendingMsgs, assistantMsg) diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 8721360f..f9ad1036 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -89,10 +89,17 @@ func (l *Loop) emitLLMSpan(ctx context.Context, start time.Time, iteration int, if resp.Usage != nil { span.InputTokens = resp.Usage.PromptTokens span.OutputTokens = resp.Usage.CompletionTokens - if resp.Usage.CacheCreationTokens > 0 || resp.Usage.CacheReadTokens > 0 { - meta := map[string]int{ - "cache_creation_tokens": resp.Usage.CacheCreationTokens, - "cache_read_tokens": resp.Usage.CacheReadTokens, + hasMeta := resp.Usage.CacheCreationTokens > 0 || resp.Usage.CacheReadTokens > 0 || resp.Usage.ThinkingTokens > 0 + if hasMeta { + meta := map[string]int{} + if resp.Usage.CacheCreationTokens > 0 { + meta["cache_creation_tokens"] = resp.Usage.CacheCreationTokens + } + if resp.Usage.CacheReadTokens > 0 { + meta["cache_read_tokens"] = resp.Usage.CacheReadTokens + } + if resp.Usage.ThinkingTokens > 0 { + meta["thinking_tokens"] = resp.Usage.ThinkingTokens } if b, err := json.Marshal(meta); err == nil { span.Metadata = b @@ -101,7 +108,11 @@ func (l *Loop) emitLLMSpan(ctx context.Context, start time.Time, iteration int, } span.FinishReason = resp.FinishReason if verbose { - span.OutputPreview = truncateStr(resp.Content, 100000) + preview := resp.Content + if resp.Thinking != "" { + preview = "\n" + resp.Thinking + "\n\n" + resp.Content + } + span.OutputPreview = truncateStr(preview, 100000) } else { span.OutputPreview = truncateStr(resp.Content, 500) } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 8b715660..0b57909c 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -281,6 +281,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, + ThinkingLevel: ag.ParseThinkingLevel(), }) slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider) diff --git a/internal/providers/anthropic.go b/internal/providers/anthropic.go index 9e417ec9..965c7186 100644 --- a/internal/providers/anthropic.go +++ b/internal/providers/anthropic.go @@ -104,7 +104,14 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onC // Accumulate raw JSON fragments for each tool call by index toolCallJSON := make(map[int]string) + // Track content blocks for RawAssistantContent (needed for thinking block passback) + var rawContentBlocks []json.RawMessage + var currentBlockType string + // Track thinking token count by accumulated chunk size + thinkingChars := 0 + scanner := bufio.NewScanner(respBody) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 1MB max line for large thinking chunks var currentEvent string for scanner.Scan() { @@ -139,6 +146,7 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onC case "content_block_start": var ev anthropicContentBlockStartEvent if err := json.Unmarshal([]byte(data), &ev); err == nil { + currentBlockType = ev.ContentBlock.Type if ev.ContentBlock.Type == "tool_use" { result.ToolCalls = append(result.ToolCalls, ToolCall{ ID: ev.ContentBlock.ID, @@ -146,24 +154,46 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onC Arguments: make(map[string]interface{}), }) } + // Store raw content_block for later reconstruction + rawContentBlocks = append(rawContentBlocks, json.RawMessage(fmt.Sprintf(`{"type":"%s"`, ev.ContentBlock.Type))) } case "content_block_delta": var ev anthropicContentBlockDeltaEvent if err := json.Unmarshal([]byte(data), &ev); err == nil { - if ev.Delta.Type == "text_delta" { + switch ev.Delta.Type { + case "text_delta": result.Content += ev.Delta.Text if onChunk != nil { onChunk(StreamChunk{Content: ev.Delta.Text}) } - } else if ev.Delta.Type == "input_json_delta" { + case "thinking_delta": + result.Thinking += ev.Delta.Thinking + thinkingChars += len(ev.Delta.Thinking) + if onChunk != nil { + onChunk(StreamChunk{Thinking: ev.Delta.Thinking}) + } + case "input_json_delta": if len(result.ToolCalls) > 0 { idx := len(result.ToolCalls) - 1 toolCallJSON[idx] += ev.Delta.PartialJSON } + case "signature_delta": + // Signature is captured in content_block_stop via raw block reconstruction } } + case "content_block_stop": + // Reconstruct the complete content block for RawAssistantContent + if len(rawContentBlocks) > 0 { + idx := len(rawContentBlocks) - 1 + block := p.buildRawBlock(currentBlockType, result, toolCallJSON, idx) + if block != nil { + rawContentBlocks[idx] = block + } + } + currentBlockType = "" + case "message_delta": var ev anthropicMessageDeltaEvent if err := json.Unmarshal([]byte(data), &ev); err == nil { @@ -207,6 +237,17 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onC if result.Usage != nil { result.Usage.TotalTokens = result.Usage.PromptTokens + result.Usage.CompletionTokens + // Estimate thinking tokens from accumulated character count (~4 chars per token) + if thinkingChars > 0 { + result.Usage.ThinkingTokens = thinkingChars / 4 + } + } + + // Preserve raw content blocks for tool use passback + if len(rawContentBlocks) > 0 && len(result.ToolCalls) > 0 { + if b, err := json.Marshal(rawContentBlocks); err == nil { + result.RawAssistantContent = b + } } if onChunk != nil { @@ -216,6 +257,58 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ChatRequest, onC return result, nil } +// buildRawBlock reconstructs a complete content block from streaming data. +// This is needed to preserve thinking blocks (with signatures) for tool use passback. +func (p *AnthropicProvider) buildRawBlock(blockType string, result *ChatResponse, toolCallJSON map[int]string, _ int) json.RawMessage { + switch blockType { + case "thinking": + block := map[string]interface{}{ + "type": "thinking", + "thinking": result.Thinking, + } + if b, err := json.Marshal(block); err == nil { + return b + } + case "text": + block := map[string]interface{}{ + "type": "text", + "text": result.Content, + } + if b, err := json.Marshal(block); err == nil { + return b + } + case "tool_use": + if len(result.ToolCalls) > 0 { + tc := result.ToolCalls[len(result.ToolCalls)-1] + // Parse accumulated JSON for this tool call + args := make(map[string]interface{}) + for i, rawJSON := range toolCallJSON { + if i == len(result.ToolCalls)-1 && rawJSON != "" { + _ = json.Unmarshal([]byte(rawJSON), &args) + } + } + block := map[string]interface{}{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Name, + "input": args, + } + if b, err := json.Marshal(block); err == nil { + return b + } + } + case "redacted_thinking": + // Pass through as-is (we don't have the encrypted data in streaming) + block := map[string]interface{}{ + "type": "redacted_thinking", + } + if b, err := json.Marshal(block); err == nil { + return b + } + } + return nil +} + func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stream bool) map[string]interface{} { // Separate system messages and build conversation messages var systemBlocks []map[string]interface{} @@ -260,6 +353,19 @@ func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stre } case "assistant": + // If we have raw content blocks (from Anthropic thinking), use them directly + // to preserve thinking blocks + signatures for tool use passback. + if msg.RawAssistantContent != nil { + var rawBlocks []json.RawMessage + if json.Unmarshal(msg.RawAssistantContent, &rawBlocks) == nil && len(rawBlocks) > 0 { + messages = append(messages, map[string]interface{}{ + "role": "assistant", + "content": rawBlocks, + }) + continue + } + } + var blocks []map[string]interface{} if msg.Content != "" { blocks = append(blocks, map[string]interface{}{ @@ -325,16 +431,45 @@ func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stre } // Merge options - if v, ok := req.Options["max_tokens"]; ok { + if v, ok := req.Options[OptMaxTokens]; ok { body["max_tokens"] = v } - if v, ok := req.Options["temperature"]; ok { + if v, ok := req.Options[OptTemperature]; ok { body["temperature"] = v } + // Enable extended thinking if thinking_level is set + if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { + budget := anthropicThinkingBudget(level) + body["thinking"] = map[string]interface{}{ + "type": "enabled", + "budget_tokens": budget, + } + // Anthropic requires no temperature when thinking is enabled + delete(body, "temperature") + // Ensure max_tokens accommodates thinking budget + response + if maxTok, ok := body["max_tokens"].(int); !ok || maxTok < budget+4096 { + body["max_tokens"] = budget + 8192 + } + } + return body } +// anthropicThinkingBudget maps a thinking level to a token budget. +func anthropicThinkingBudget(level string) int { + switch level { + case "low": + return 4096 + case "medium": + return 10000 + case "high": + return 32000 + default: + return 10000 + } +} + func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) { data, err := json.Marshal(body) if err != nil { @@ -350,6 +485,13 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io httpReq.Header.Set("x-api-key", p.apiKey) httpReq.Header.Set("anthropic-version", anthropicAPIVersion) + // Add beta header for interleaved thinking when thinking is enabled + if bodyMap, ok := body.(map[string]interface{}); ok { + if _, hasThinking := bodyMap["thinking"]; hasThinking { + httpReq.Header.Set("anthropic-beta", "interleaved-thinking-2025-05-14") + } + } + resp, err := p.client.Do(httpReq) if err != nil { return nil, fmt.Errorf("anthropic: request failed: %w", err) @@ -371,11 +513,17 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io func (p *AnthropicProvider) parseResponse(resp *anthropicResponse) *ChatResponse { result := &ChatResponse{} + thinkingChars := 0 for _, block := range resp.Content { switch block.Type { case "text": result.Content += block.Text + case "thinking": + result.Thinking += block.Thinking + thinkingChars += len(block.Thinking) + case "redacted_thinking": + // Encrypted thinking — cannot display but must preserve for passback case "tool_use": args := make(map[string]interface{}) _ = json.Unmarshal(block.Input, &args) @@ -403,6 +551,16 @@ func (p *AnthropicProvider) parseResponse(resp *anthropicResponse) *ChatResponse CacheCreationTokens: resp.Usage.CacheCreationInputTokens, CacheReadTokens: resp.Usage.CacheReadInputTokens, } + if thinkingChars > 0 { + result.Usage.ThinkingTokens = thinkingChars / 4 + } + + // Preserve raw content blocks for tool use passback + if len(result.ToolCalls) > 0 { + if b, err := json.Marshal(resp.Content); err == nil { + result.RawAssistantContent = b + } + } return result } @@ -416,11 +574,14 @@ type anthropicResponse struct { } type anthropicContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` // for type="thinking" + Signature string `json:"signature,omitempty"` // encrypted thinking verification + Data string `json:"data,omitempty"` // for type="redacted_thinking" + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` } type anthropicUsage struct { @@ -447,6 +608,8 @@ type anthropicContentBlockDeltaEvent struct { Delta struct { Type string `json:"type"` Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` // for thinking_delta + Signature string `json:"signature,omitempty"` // for signature_delta PartialJSON string `json:"partial_json,omitempty"` } `json:"delta"` } diff --git a/internal/providers/dashscope.go b/internal/providers/dashscope.go index b08df9b0..ae33f75a 100644 --- a/internal/providers/dashscope.go +++ b/internal/providers/dashscope.go @@ -35,6 +35,19 @@ func (p *DashScopeProvider) Name() string { return "dashscope" } // When tools are present, falls back to non-streaming Chat() and synthesizes // chunk callbacks for the caller. func (p *DashScopeProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + // Map thinking_level to DashScope-specific params before passing to OpenAI base + if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { + // Clone Options to avoid mutating caller's map + opts := make(map[string]interface{}, len(req.Options)+2) + for k, v := range req.Options { + opts[k] = v + } + opts[OptEnableThinking] = true + opts[OptThinkingBudget] = dashscopeThinkingBudget(level) + delete(opts, OptThinkingLevel) // don't pass generic key to OpenAI buildRequestBody + req.Options = opts + } + if len(req.Tools) > 0 { slog.Debug("dashscope: tools present, falling back to non-streaming Chat") resp, err := p.Chat(ctx, req) @@ -54,3 +67,17 @@ func (p *DashScopeProvider) ChatStream(ctx context.Context, req ChatRequest, onC } return p.OpenAIProvider.ChatStream(ctx, req, onChunk) } + +// dashscopeThinkingBudget maps a thinking level to a DashScope thinking_budget value. +func dashscopeThinkingBudget(level string) int { + switch level { + case "low": + return 4096 + case "medium": + return 16384 + case "high": + return 32768 + default: + return 16384 + } +} diff --git a/internal/providers/openai.go b/internal/providers/openai.go index d7805538..05c507c4 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -166,6 +166,9 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChun if chunk.Usage.PromptTokensDetails != nil { result.Usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens } + if chunk.Usage.CompletionTokensDetails != nil && chunk.Usage.CompletionTokensDetails.ReasoningTokens > 0 { + result.Usage.ThinkingTokens = chunk.Usage.CompletionTokensDetails.ReasoningTokens + } } } @@ -283,13 +286,26 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream } // Merge options - if v, ok := req.Options["max_tokens"]; ok { + if v, ok := req.Options[OptMaxTokens]; ok { body["max_tokens"] = v } - if v, ok := req.Options["temperature"]; ok { + if v, ok := req.Options[OptTemperature]; ok { body["temperature"] = v } + // Inject reasoning_effort for o-series models (ignored by models that don't support it) + if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" { + body[OptReasoningEffort] = level + } + + // DashScope-specific passthrough keys + if v, ok := req.Options[OptEnableThinking]; ok { + body[OptEnableThinking] = v + } + if v, ok := req.Options[OptThinkingBudget]; ok { + body[OptThinkingBudget] = v + } + return body } @@ -363,6 +379,9 @@ func (p *OpenAIProvider) parseResponse(resp *openAIResponse) *ChatResponse { if resp.Usage.PromptTokensDetails != nil { result.Usage.CacheReadTokens = resp.Usage.PromptTokensDetails.CachedTokens } + if resp.Usage.CompletionTokensDetails != nil && resp.Usage.CompletionTokensDetails.ReasoningTokens > 0 { + result.Usage.ThinkingTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens + } } return result diff --git a/internal/providers/openai_types.go b/internal/providers/openai_types.go index 4de0aaec..691b1d6b 100644 --- a/internal/providers/openai_types.go +++ b/internal/providers/openai_types.go @@ -32,16 +32,21 @@ type openAIFunctionCall struct { } type openAIUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - PromptTokensDetails *openAIPromptDetails `json:"prompt_tokens_details,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokensDetails *openAIPromptDetails `json:"prompt_tokens_details,omitempty"` + CompletionTokensDetails *openAICompletionDetails `json:"completion_tokens_details,omitempty"` } type openAIPromptDetails struct { CachedTokens int `json:"cached_tokens"` } +type openAICompletionDetails struct { + ReasoningTokens int `json:"reasoning_tokens"` +} + // Streaming types type openAIStreamChunk struct { diff --git a/internal/providers/types.go b/internal/providers/types.go index 6d52dc91..e108d4d4 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -1,6 +1,19 @@ package providers -import "context" +import ( + "context" + "encoding/json" +) + +// Options keys used in ChatRequest.Options across providers. +const ( + OptMaxTokens = "max_tokens" + OptTemperature = "temperature" + OptThinkingLevel = "thinking_level" + OptReasoningEffort = "reasoning_effort" + OptEnableThinking = "enable_thinking" + OptThinkingBudget = "thinking_budget" +) // Provider is the interface all LLM providers must implement. type Provider interface { @@ -34,6 +47,10 @@ type ChatResponse struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` FinishReason string `json:"finish_reason"` // "stop", "tool_calls", "length" Usage *Usage `json:"usage,omitempty"` + + // RawAssistantContent preserves the raw content blocks array from the provider response. + // Used by Anthropic to pass thinking blocks back in tool use loops (required by API). + RawAssistantContent json.RawMessage `json:"-"` } // StreamChunk is a piece of a streaming response. @@ -56,6 +73,10 @@ type Message struct { Images []ImageContent `json:"images,omitempty"` // vision: base64 images ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` // for role="tool" responses + + // RawAssistantContent carries raw provider content blocks through tool loop iterations. + // Anthropic requires thinking blocks to be passed back exactly as received. + RawAssistantContent json.RawMessage `json:"-"` } // ToolCall represents a tool invocation requested by the LLM. @@ -86,4 +107,5 @@ type Usage struct { TotalTokens int `json:"total_tokens"` CacheCreationTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadTokens int `json:"cache_read_input_tokens,omitempty"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` } diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index e56fc820..1ec21683 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -121,6 +121,21 @@ func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig { return &c } +// ParseThinkingLevel extracts thinking_level from other_config JSONB. +// Returns "" if not configured (meaning "off"). +func (a *AgentData) ParseThinkingLevel() string { + if len(a.OtherConfig) == 0 { + return "" + } + var cfg struct { + ThinkingLevel string `json:"thinking_level"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil { + return "" + } + return cfg.ThinkingLevel +} + // AgentShareData represents an agent share grant. type AgentShareData struct { BaseModel diff --git a/ui/web/src/components/chat/thinking-block.tsx b/ui/web/src/components/chat/thinking-block.tsx new file mode 100644 index 00000000..11b1749e --- /dev/null +++ b/ui/web/src/components/chat/thinking-block.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Brain } from "lucide-react"; + +interface ThinkingBlockProps { + text: string; + isStreaming?: boolean; +} + +export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps) { + const [expanded, setExpanded] = useState(isStreaming); + + return ( +
+ + {expanded && ( +
+
+            {text}
+            {isStreaming && (
+              
+            )}
+          
+
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx index 048ffd19..cffd98b2 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-config-tab.tsx @@ -21,6 +21,7 @@ import { MemorySection, OtherConfigSection, QualityGatesSection, + ThinkingSection, } from "./config-sections"; interface AgentConfigTabProps { @@ -47,19 +48,22 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { const [memEnabled, setMemEnabled] = useState(agent.memory_config != null); const [mem, setMem] = useState(agent.memory_config ?? {}); - // Extract quality_gates from other_config, manage separately + // Extract managed keys from other_config, manage separately const otherObj = (agent.other_config ?? {}) as Record; const initialGates = (Array.isArray(otherObj.quality_gates) ? otherObj.quality_gates : []) as QualityGateConfig[]; - const { quality_gates: _qg, ...otherWithoutGates } = otherObj; + const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off"); + const { quality_gates: _qg, thinking_level: _tl, ...otherWithoutManaged } = otherObj; const [qgEnabled, setQgEnabled] = useState(initialGates.length > 0); const [qualityGates, setQualityGates] = useState(initialGates); + const [thinkingLevel, setThinkingLevel] = useState(initialThinkingLevel); + const [otherEnabled, setOtherEnabled] = useState( - agent.other_config != null && Object.keys(otherWithoutGates).length > 0, + agent.other_config != null && Object.keys(otherWithoutManaged).length > 0, ); const [otherJson, setOtherJson] = useState( - Object.keys(otherWithoutGates).length > 0 ? JSON.stringify(otherWithoutGates, null, 2) : "{}", + Object.keys(otherWithoutManaged).length > 0 ? JSON.stringify(otherWithoutManaged, null, 2) : "{}", ); const [saving, setSaving] = useState(false); @@ -87,6 +91,9 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { if (qgEnabled && qualityGates.length > 0) { otherBase.quality_gates = qualityGates; } + if (thinkingLevel && thinkingLevel !== "off") { + otherBase.thinking_level = thinkingLevel; + } updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {}; await onUpdate(updates); setSaved(true); @@ -142,6 +149,11 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) { onChange={setMem} /> + + void; +} + +export function ThinkingSection({ value, onChange }: ThinkingSectionProps) { + return ( +
+
+

Extended Thinking

+

+ Allow the model to reason before responding. Higher levels use more + tokens but produce better results on complex tasks. +

+
+
+ + Thinking Level + + +
+
+ ); +} diff --git a/ui/web/src/pages/chat/chat-page.tsx b/ui/web/src/pages/chat/chat-page.tsx index 9d9219a1..c2cfbb4a 100644 --- a/ui/web/src/pages/chat/chat-page.tsx +++ b/ui/web/src/pages/chat/chat-page.tsx @@ -29,6 +29,7 @@ export function ChatPage() { const { messages, streamText, + thinkingText, toolStream, isRunning, loading: messagesLoading, @@ -133,6 +134,7 @@ export function ChatPage() { ( - [messages.length, streamText, toolStream.length], + [messages.length, streamText, thinkingText, toolStream.length], ); // Show spinner while loading history for a different session @@ -62,6 +65,13 @@ export function ChatThread({ )} + {/* Thinking block (extended thinking / reasoning) */} + {isRunning && thinkingText && ( +
+ +
+ )} + {/* Streaming text */} {isRunning && streamText !== null && (
@@ -77,7 +87,7 @@ export function ChatThread({ )} {/* Thinking indicator when running but no stream yet */} - {isRunning && streamText === null && toolStream.length === 0 && ( + {isRunning && streamText === null && !thinkingText && toolStream.length === 0 && (
diff --git a/ui/web/src/pages/chat/hooks/use-chat-messages.ts b/ui/web/src/pages/chat/hooks/use-chat-messages.ts index 5607ad4e..df78b360 100644 --- a/ui/web/src/pages/chat/hooks/use-chat-messages.ts +++ b/ui/web/src/pages/chat/hooks/use-chat-messages.ts @@ -16,6 +16,7 @@ export function useChatMessages(sessionKey: string, agentId: string) { const ws = useWs(); const [messages, setMessages] = useState([]); const [streamText, setStreamText] = useState(null); + const [thinkingText, setThinkingText] = useState(null); const [toolStream, setToolStream] = useState([]); const [isRunning, setIsRunning] = useState(false); const [loading, setLoading] = useState(false); @@ -24,6 +25,7 @@ export function useChatMessages(sessionKey: string, agentId: string) { const runIdRef = useRef(null); const expectingRunRef = useRef(false); const streamRef = useRef(""); + const thinkingRef = useRef(""); const agentIdRef = useRef(agentId); agentIdRef.current = agentId; @@ -34,12 +36,14 @@ export function useChatMessages(sessionKey: string, agentId: string) { setPrevKey(sessionKey); setMessages([]); setStreamText(null); + setThinkingText(null); setToolStream([]); setIsRunning(false); setLoading(true); runIdRef.current = null; expectingRunRef.current = false; streamRef.current = ""; + thinkingRef.current = ""; } // Load history (no loading spinner — the empty state placeholder is shown instead) @@ -90,8 +94,10 @@ export function useChatMessages(sessionKey: string, agentId: string) { expectingRunRef.current = false; setIsRunning(true); setStreamText(null); + setThinkingText(null); setToolStream([]); streamRef.current = ""; + thinkingRef.current = ""; } return; } @@ -100,6 +106,12 @@ export function useChatMessages(sessionKey: string, agentId: string) { if (!runIdRef.current || event.runId !== runIdRef.current) return; switch (event.type) { + case "thinking": { + const content = event.payload?.content ?? ""; + thinkingRef.current += content; + setThinkingText(thinkingRef.current); + break; + } case "chunk": { const content = event.payload?.content ?? ""; streamRef.current += content; @@ -137,16 +149,20 @@ export function useChatMessages(sessionKey: string, agentId: string) { runIdRef.current = null; loadHistory(); setStreamText(null); + setThinkingText(null); setToolStream([]); streamRef.current = ""; + thinkingRef.current = ""; break; } case "run.failed": { setIsRunning(false); runIdRef.current = null; setStreamText(null); + setThinkingText(null); setToolStream([]); streamRef.current = ""; + thinkingRef.current = ""; setMessages((prev) => [ ...prev, { @@ -172,6 +188,7 @@ export function useChatMessages(sessionKey: string, agentId: string) { return { messages, streamText, + thinkingText, toolStream, isRunning, loading, diff --git a/ui/web/src/pages/traces/hooks/use-traces.ts b/ui/web/src/pages/traces/hooks/use-traces.ts index 370dbe90..fa3ac35d 100644 --- a/ui/web/src/pages/traces/hooks/use-traces.ts +++ b/ui/web/src/pages/traces/hooks/use-traces.ts @@ -50,7 +50,7 @@ export interface SpanData { tool_call_id: string; input_preview: string; output_preview: string; - metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number }; + metadata?: { cache_creation_tokens?: number; cache_read_tokens?: number; thinking_tokens?: number }; } interface TraceFilters { diff --git a/ui/web/src/pages/traces/trace-detail-dialog.tsx b/ui/web/src/pages/traces/trace-detail-dialog.tsx index d36ce0fd..0768658b 100644 --- a/ui/web/src/pages/traces/trace-detail-dialog.tsx +++ b/ui/web/src/pages/traces/trace-detail-dialog.tsx @@ -217,6 +217,11 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) { ({formatTokens(span.metadata!.cache_read_tokens!)} cached) )} + {(span.metadata?.thinking_tokens ?? 0) > 0 && ( + + ({formatTokens(span.metadata!.thinking_tokens!)} thinking) + + )} )} @@ -250,6 +255,11 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) { ) )} + {(span.metadata?.thinking_tokens ?? 0) > 0 && ( + + ({formatTokens(span.metadata!.thinking_tokens!)} thinking) + + )}
)} {span.input_preview && (