mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
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:
1 parent
d913f29c69
commit
552ddc059e
18 files changed
+455
-33
No files matched your search
+17
-5
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user