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 && (
)}
{span.input_preview && (