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 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-02-28 15:45:34 +07:00
1 parent d913f29c69
commit 552ddc059e
18 files changed
+455 -33

No files matched your search

+17 -5
View File
@@ -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)
+16 -5
View File
@@ -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 = "<thinking>\n" + resp.Thinking + "\n</thinking>\n" + resp.Content
}
span.OutputPreview = truncateStr(preview, 100000)
} else {
span.OutputPreview = truncateStr(resp.Content, 500)
}
+1
View File
@@ -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)
+172 -9
View File
@@ -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"`
}
+27
View File
@@ -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
}
}
+21 -2
View File
@@ -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
+9 -4
View File
@@ -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 {
+23 -1
View File
@@ -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"`
}
+15
View File
@@ -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
@@ -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 (
<div className="rounded-lg border border-muted bg-muted/30 text-sm">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<Brain className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium">
Thinking{isStreaming ? "..." : ""}
</span>
{expanded ? (
<ChevronDown className="ml-auto h-3.5 w-3.5" />
) : (
<ChevronRight className="ml-auto h-3.5 w-3.5" />
)}
</button>
{expanded && (
<div className="border-t border-muted px-3 py-2">
<pre className="whitespace-pre-wrap text-xs text-muted-foreground font-mono leading-relaxed max-h-80 overflow-y-auto">
{text}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 bg-muted-foreground/50 animate-pulse ml-0.5 align-text-bottom" />
)}
</pre>
</div>
)}
</div>
);
}
@@ -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<MemoryConfig>(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<string, unknown>;
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<QualityGateConfig[]>(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}
/>
<Separator />
<ThinkingSection
value={thinkingLevel}
onChange={setThinkingLevel}
/>
<Separator />
<QualityGatesSection
enabled={qgEnabled}
value={qualityGates}
@@ -7,3 +7,4 @@ export { SandboxSection } from "./sandbox-section";
export { MemorySection } from "./memory-section";
export { OtherConfigSection } from "./other-config-section";
export { QualityGatesSection } from "./quality-gates-section";
export { ThinkingSection } from "./thinking-section";
@@ -0,0 +1,54 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { InfoLabel } from "./config-section";
const THINKING_LEVELS = [
{ value: "off", label: "Off", description: "No extended thinking" },
{ value: "low", label: "Low", description: "~4K token budget" },
{ value: "medium", label: "Medium", description: "~10-16K token budget" },
{ value: "high", label: "High", description: "~32K token budget" },
] as const;
interface ThinkingSectionProps {
value: string;
onChange: (v: string) => void;
}
export function ThinkingSection({ value, onChange }: ThinkingSectionProps) {
return (
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium">Extended Thinking</h3>
<p className="text-xs text-muted-foreground">
Allow the model to reason before responding. Higher levels use more
tokens but produce better results on complex tasks.
</p>
</div>
<div className="space-y-2">
<InfoLabel tip="Thinking level controls the token budget for reasoning. Anthropic uses budget_tokens, OpenAI uses reasoning_effort, DashScope uses thinking_budget. Token budgets vary by provider.">
Thinking Level
</InfoLabel>
<Select value={value || "off"} onValueChange={onChange}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>
<span>{level.label}</span>
<span className="ml-2 text-xs text-muted-foreground">
{level.description}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</section>
);
}
+2
View File
@@ -29,6 +29,7 @@ export function ChatPage() {
const {
messages,
streamText,
thinkingText,
toolStream,
isRunning,
loading: messagesLoading,
@@ -133,6 +134,7 @@ export function ChatPage() {
<ChatThread
messages={messages}
streamText={streamText}
thinkingText={thinkingText}
toolStream={toolStream}
isRunning={isRunning}
loading={messagesLoading}
+12 -2
View File
@@ -2,12 +2,14 @@ import { MessageBubble } from "@/components/chat/message-bubble";
import { StreamingText } from "@/components/chat/streaming-text";
import { ToolCallCard } from "@/components/chat/tool-call-card";
import { ThinkingIndicator } from "@/components/chat/thinking-indicator";
import { ThinkingBlock } from "@/components/chat/thinking-block";
import { useAutoScroll } from "@/hooks/use-auto-scroll";
import type { ChatMessage, ToolStreamEntry } from "@/types/chat";
interface ChatThreadProps {
messages: ChatMessage[];
streamText: string | null;
thinkingText: string | null;
toolStream: ToolStreamEntry[];
isRunning: boolean;
loading?: boolean;
@@ -16,12 +18,13 @@ interface ChatThreadProps {
export function ChatThread({
messages,
streamText,
thinkingText,
toolStream,
isRunning,
loading,
}: ChatThreadProps) {
const { ref, onScroll } = useAutoScroll<HTMLDivElement>(
[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({
</div>
)}
{/* Thinking block (extended thinking / reasoning) */}
{isRunning && thinkingText && (
<div className="mx-auto max-w-[80%]">
<ThinkingBlock text={thinkingText} isStreaming={streamText === null} />
</div>
)}
{/* Streaming text */}
{isRunning && streamText !== null && (
<div className="flex gap-3">
@@ -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 && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border bg-background">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -16,6 +16,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const ws = useWs();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamText, setStreamText] = useState<string | null>(null);
const [thinkingText, setThinkingText] = useState<string | null>(null);
const [toolStream, setToolStream] = useState<ToolStreamEntry[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [loading, setLoading] = useState(false);
@@ -24,6 +25,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
const runIdRef = useRef<string | null>(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,
+1 -1
View File
@@ -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 {
@@ -217,6 +217,11 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
({formatTokens(span.metadata!.cache_read_tokens!)} cached)
</span>
)}
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
<span className="ml-1 text-purple-400" title="Thinking tokens">
({formatTokens(span.metadata!.thinking_tokens!)} thinking)
</span>
)}
</span>
)}
<span className="shrink-0 text-xs text-muted-foreground">
@@ -250,6 +255,11 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
)
</span>
)}
{(span.metadata?.thinking_tokens ?? 0) > 0 && (
<span className="ml-2 text-muted-foreground">
(<span className="text-purple-400">{formatTokens(span.metadata!.thinking_tokens!)} thinking</span>)
</span>
)}
</div>
)}
{span.input_preview && (