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 && (