From d92664d0735441dd3f476b84685656c7dd1b37d1 Mon Sep 17 00:00:00 2001 From: yupanzi <184800434+yupanzi@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:13:21 +0800 Subject: [PATCH] feat(ai): modernize the Anthropic path and fix conversation replay Update the Claude integration to the current API surface and fix several agent-loop bugs: - Move the Anthropic path to the Beta Messages API with adaptive thinking, context management, and output_config.effort. Effort is the reasoning-depth knob on current Claude models (budget_tokens returns 400 there); it is exposed as a new AIEffort setting (low/medium/high/xhigh/max, default xhigh) in the general settings. Feature use is gated per model so older models keep the classic request shape. - Send AIMaxTokens as configured and make the default provider-aware (64000 for Anthropic, 8192 for OpenAI): on current Claude models thinking and answer share max_tokens, so the old 4096 default truncated answers mid-sentence. Default Anthropic model is now claude-opus-5. - Replay tool turns structurally instead of flattening them to "[Tool: ...]" text. Textual replay poisoned the model into emitting tool calls as plain text/XML on later turns; the frontend now sends the tool round-trip (id, name, args, result) and the backend rebuilds real tool_use/tool_result blocks. - Mask Secret data in tool results before they reach the model. - Emit an SSE keepalive comment every 20s: an agent turn is legitimately silent while a tool runs, and ingress-nginx closes the connection after 60s of backend silence. Raise the chart's gateway request timeout to 900s and document the matching ingress-nginx annotations next to it. - Tighten resource tool schemas/descriptions with explicit caps, and fix long-token overflow in AI chat markdown tables. - Cover conversation replay, handler streaming, settings migration, and the agent loop with tests. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 26 ++ charts/kite/templates/gateway.yaml | 2 +- charts/kite/values.yaml | 15 +- pkg/ai/agent.go | 302 ++++++++++++++-- pkg/ai/agent_test.go | 25 +- pkg/ai/anthropic.go | 296 +++++++++++++--- pkg/ai/config.go | 4 +- pkg/ai/conversation_history_test.go | 332 ++++++++++++++++++ pkg/ai/handler.go | 96 +++-- pkg/ai/handler_test.go | 104 ++++++ pkg/ai/openai.go | 26 +- pkg/ai/pending_session.go | 2 +- pkg/ai/tool_resource_execution.go | 121 ++++++- pkg/ai/tool_resource_execution_test.go | 94 ++++- pkg/ai/tools.go | 22 +- pkg/model/general_setting.go | 75 +++- pkg/model/general_setting_upgrade_test.go | 137 ++++++++ pkg/settings/handler.go | 16 +- .../components/ai-chat/ai-chat-messages.tsx | 2 +- ui/src/components/ai-chat/ai-chat-types.ts | 15 +- .../settings/general-management.tsx | 84 ++++- ui/src/hooks/use-ai-chat.ts | 20 +- ui/src/i18n/locales/en.json | 5 +- ui/src/i18n/locales/zh.json | 5 +- ui/src/lib/api/admin.ts | 4 + ui/src/styles/base.css | 4 + 26 files changed, 1665 insertions(+), 169 deletions(-) create mode 100644 pkg/ai/conversation_history_test.go create mode 100644 pkg/ai/handler_test.go create mode 100644 pkg/model/general_setting_upgrade_test.go diff --git a/AGENTS.md b/AGENTS.md index 16f1dd03..7d627ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,32 @@ The backend package layout is feature-oriented: - `pkg/ai` owns provider configuration, chat handling, tool definitions, interaction pauses, Kubernetes tool execution, and tool authorization. +### AI request budgets + +Two settings control model behaviour and they are not interchangeable: + +- `AIMaxTokens` is a per-response ceiling. On current Claude models thinking and + answer text share it, so a small value truncates the answer. It is sent to the + provider as configured — never clamped, floored, or rejected, because only the + provider knows the configured model's real limit. +- `AIEffort` (`output_config.effort`) is the reasoning-depth knob and the only + one: `budget_tokens` is removed on current models and returns 400. Levels are + `low`/`medium`/`high`/`xhigh`/`max`, default `xhigh`. Anthropic path only. + +`anthropicModelSupportsModernFeatures` gates effort, adaptive thinking, and +context management behind a deny list of model-name substrings. That list tracks +*request-surface support*, not lifecycle — Opus 4.5, Sonnet 4.5, and Haiku 4.5 +are all still sold but reject the modern surface, and retired first-party models +stay listed because they remain available through Bedrock and Google Cloud. A +false negative here silently downgrades a capable model; there is no retry on a +400, so widening the gate needs a fallback path first. + +SSE streams (`newStreamSender` in `pkg/ai/handler.go`) emit a keepalive comment +every 20s. An agent turn is legitimately silent while a tool runs, and +ingress-nginx closes a connection after 60s of backend silence. Chart timeouts +and the ingress annotation examples in `charts/kite/values.yaml` are the other +half of this — change them together. + ## Request flow Most protected API calls go through: diff --git a/charts/kite/templates/gateway.yaml b/charts/kite/templates/gateway.yaml index 349886da..0272eb65 100644 --- a/charts/kite/templates/gateway.yaml +++ b/charts/kite/templates/gateway.yaml @@ -92,7 +92,7 @@ spec: - name: {{ include "kite.fullname" $ }} port: {{ $.Values.service.port }} timeouts: - request: {{ if .timeouts }}{{ .timeouts.request | default "120s" }}{{ else }}"120s"{{ end }} + request: {{ if .timeouts }}{{ .timeouts.request | default "900s" }}{{ else }}"900s"{{ end }} {{- end }} {{- end }} {{- end }} diff --git a/charts/kite/values.yaml b/charts/kite/values.yaml index 89e129f8..daa4c71f 100644 --- a/charts/kite/values.yaml +++ b/charts/kite/values.yaml @@ -252,6 +252,16 @@ service: ingress: enabled: false className: "nginx" + # ingress-nginx defaults will cut off AI chat and terminal streams: it closes a + # connection after 60s with no bytes from the backend, and rejects bodies over + # 1m. An SSE agent turn is legitimately silent while a tool runs, and the chat + # request carries the whole transcript. When enabling ingress, replace the + # empty map below with these annotations: + # annotations: + # nginx.ingress.kubernetes.io/proxy-read-timeout: "900" + # nginx.ingress.kubernetes.io/proxy-send-timeout: "900" + # nginx.ingress.kubernetes.io/proxy-body-size: "32m" + # nginx.ingress.kubernetes.io/proxy-buffering: "off" annotations: {} hosts: - host: kite.zzde.me @@ -317,7 +327,10 @@ gateway: type: PathPrefix value: / timeouts: - request: "120s" + # An AI agent turn streams over SSE for as long as the model keeps + # calling tools, which routinely exceeds two minutes. A shorter + # timeout cuts the stream mid-answer with no error the user can act on. + request: "900s" # Example: additional route for different hostname/path # - name: kite-api # annotations: {} diff --git a/pkg/ai/agent.go b/pkg/ai/agent.go index ee153608..f30dedbb 100644 --- a/pkg/ai/agent.go +++ b/pkg/ai/agent.go @@ -3,9 +3,11 @@ package ai import ( "encoding/json" "fmt" + "regexp" "sort" "strings" "time" + "unicode/utf8" anthropic "github.com/anthropics/anthropic-sdk-go" "github.com/gin-gonic/gin" @@ -27,6 +29,7 @@ You have access to tools that let you interact with the user's Kubernetes cluste - Create, update, patch or delete resources Operating principles: +- Tool-calling discipline: ALWAYS invoke tools through the native tool-calling mechanism. NEVER write a tool call as text or XML in your message — do not output strings like "", "", or "[Tool: ...]". Any tool call written as plain text is NOT executed and is a bug. If you intend to use a tool, emit a real tool call. - Evidence first: collect relevant cluster state before conclusions. Do not guess cluster state. - Read before write: before any mutation operation (create/update/patch/delete), inspect current related resources unless the request is an explicit create with complete details. - Verify after write: after a mutation, re-check the affected resource(s) and report whether the change actually took effect. @@ -67,9 +70,20 @@ Response style: - Feel free to respond with emojis where appropriate.` // ChatMessage represents a message in the conversation. +// +// A "tool" role message carries a full tool round-trip (the model's tool call +// plus its result) structurally, so the backend can rebuild real +// tool_use/tool_result blocks. Feeding tool calls back as flattened text +// poisons the model into emitting textual/XML tool calls on later turns. type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` + // Tool round-trip fields (only set when Role == "tool"). + ToolCallID string `json:"tool_call_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs map[string]interface{} `json:"tool_args,omitempty"` + ToolResult string `json:"tool_result,omitempty"` + IsError bool `json:"is_error,omitempty"` } // PageContext provides context about which page the user is viewing. @@ -101,6 +115,7 @@ type Agent struct { cs *cluster.ClientSet model string maxTokens int + effort string } type runtimePromptContext struct { @@ -109,8 +124,33 @@ type runtimePromptContext struct { RBACOverview string } -const maxConversationMessages = 30 -const maxMessageChars = 8000 +// Conversation/message truncation limits. User content and tool results get +// separate budgets: a user message is human-typed and self-limiting, while a +// tool result is sized by the model and one bad call can produce megabytes. +// The Anthropic path targets the current Claude models (1M-token context +// window); the OpenAI path must stay safe for smaller context windows. +// +// maxTotalChars is the load-bearing bound. Per-message caps multiplied by the +// message count do not bound a request — 300 x 200000 is ~15M tokens, far past +// any context window — and the server-side clear_tool_uses backstop only runs on +// the modern Anthropic shape, so legacy models have none. The aggregate budget is +// sized for the smallest context window each provider is realistically pointed +// at (~200K tokens), which holds for a 1M-window model too. +const ( + maxOpenAIConversationMessages = 30 + maxOpenAIMessageChars = 8000 + maxOpenAIToolResultChars = 8000 + maxOpenAITotalChars = 120000 + + maxAnthropicConversationMessages = 300 + maxAnthropicMessageChars = 200000 + maxAnthropicToolResultChars = 30000 + maxAnthropicTotalChars = 600000 +) + +// truncationNotice is appended to content that had to be cut, so neither the +// model nor the user silently reads a sentence that stops mid-word. +const truncationNotice = "\n\n[... truncated by Kite: content exceeded the per-message limit ...]" // NewAgent creates a new AI agent for a conversation. func NewAgent(cs *cluster.ClientSet, cfg *RuntimeConfig) (*Agent, error) { @@ -124,16 +164,22 @@ func NewAgent(cs *cluster.ClientSet, cfg *RuntimeConfig) (*Agent, error) { modelName = cfg.Model } - maxTokens := 4096 + maxTokens := model.DefaultGeneralAIMaxTokensByProvider(provider) if cfg != nil && cfg.MaxTokens > 0 { maxTokens = cfg.MaxTokens } + effort := model.DefaultGeneralAIEffort + if cfg != nil && cfg.Effort != "" { + effort = model.NormalizeGeneralAIEffort(cfg.Effort) + } + agent := &Agent{ provider: provider, cs: cs, model: modelName, maxTokens: maxTokens, + effort: effort, } switch provider { @@ -153,34 +199,200 @@ func NewAgent(cs *cluster.ClientSet, cfg *RuntimeConfig) (*Agent, error) { return agent, nil } -func normalizeChatMessages(chatMessages []ChatMessage) []ChatMessage { - if len(chatMessages) > maxConversationMessages { - chatMessages = chatMessages[len(chatMessages)-maxConversationMessages:] + +// conversationLimits groups the truncation budgets for one provider so call +// sites read by name instead of by the position of four bare ints. +type conversationLimits struct { + maxMessages int + maxChars int + maxToolResultChars int + maxTotalChars int +} + +var ( + openAILimits = conversationLimits{ + maxMessages: maxOpenAIConversationMessages, + maxChars: maxOpenAIMessageChars, + maxToolResultChars: maxOpenAIToolResultChars, + maxTotalChars: maxOpenAITotalChars, + } + anthropicLimits = conversationLimits{ + maxMessages: maxAnthropicConversationMessages, + maxChars: maxAnthropicMessageChars, + maxToolResultChars: maxAnthropicToolResultChars, + maxTotalChars: maxAnthropicTotalChars, + } +) + +func normalizeChatMessages(chatMessages []ChatMessage, limits conversationLimits) []ChatMessage { + if len(chatMessages) > limits.maxMessages { + chatMessages = chatMessages[len(chatMessages)-limits.maxMessages:] } normalized := make([]ChatMessage, 0, len(chatMessages)) for _, msg := range chatMessages { - content := strings.TrimSpace(msg.Content) - if content == "" { + if msg.Role == "tool" { + // Structured tool round-trip. Keep only when it carries a usable + // id+name+result triple; a tool_use missing any of them (or its + // matching tool_result) would make the provider request invalid. + if strings.TrimSpace(msg.ToolCallID) == "" || strings.TrimSpace(msg.ToolName) == "" || strings.TrimSpace(msg.ToolResult) == "" { + continue + } + normalized = append(normalized, ChatMessage{ + Role: "tool", + ToolCallID: msg.ToolCallID, + ToolName: msg.ToolName, + ToolArgs: msg.ToolArgs, + ToolResult: truncateWithNotice(msg.ToolResult, limits.maxToolResultChars, "tool result "+msg.ToolName), + IsError: msg.IsError, + }) continue } - if len(content) > maxMessageChars { - content = content[:maxMessageChars] - } + + content := strings.TrimSpace(msg.Content) role := "user" if msg.Role == "assistant" { role = "assistant" + // Defensive rescue: strip any tool calls a previous (broken) turn + // leaked as text/XML, so a poisoned history doesn't re-poison the + // model on this turn. + content = strings.TrimSpace(stripLeakedToolCalls(content)) + } + + if content == "" { + continue } normalized = append(normalized, ChatMessage{ Role: role, - Content: content, + Content: truncateWithNotice(content, limits.maxChars, role+" message"), }) } + + normalized = trimToTotalBudget(normalized, limits.maxTotalChars) + + // The (possibly truncated) history must start with a user turn so the + // reconstructed provider messages satisfy the "first message must be user" + // rule and never begin with an orphaned tool_result. + for len(normalized) > 0 && normalized[0].Role != "user" { + normalized = normalized[1:] + } + return normalized } +// truncateRunes caps s at max runes (not bytes), so multi-byte UTF-8 content +// (e.g. Chinese tool output) is never split mid-rune into an invalid sequence. +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + // Byte length <= max guarantees rune count <= max — skip the rune scan. + if len(s) <= max { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} + +// truncationNoticeRunes is the notice's own cost against a message budget. +var truncationNoticeRunes = utf8.RuneCountInString(truncationNotice) + +// truncateWithNotice caps s at max runes and, when content was actually cut, +// appends truncationNotice so the model does not read a sentence that stops +// mid-word with no indication anything is missing. The notice is counted against +// max, so the result never exceeds the cap. +func truncateWithNotice(s string, max int, label string) string { + if max <= 0 { + return "" + } + // Byte length <= max guarantees rune count <= max, so the common (uncut) + // case never materializes a []rune. Only content past the byte bound pays + // the rune count, and that count is exact — unlike len(s) it does not + // over-report multi-byte content that actually fits. + if len(s) <= max { + return s + } + if utf8.RuneCountInString(s) <= max { + return s + } + keep := max - truncationNoticeRunes + if keep <= 0 { + // The budget cannot fit the notice; fall back to a plain hard cut. + return truncateRunes(s, max) + } + klog.V(2).Infof("AI conversation: truncated %s to %d runes (limit %d)", label, keep, max) + return truncateRunes(s, keep) + truncationNotice +} + +// trimToTotalBudget drops whole messages, oldest first, until the transcript +// fits maxTotalChars. Per-message caps bound one message; only this bounds the +// request, which is what the provider's context window actually limits. Tool +// round-trips are dropped with their result so no orphaned tool_result survives +// (the id/name/result triple is kept intact or removed entirely). +func trimToTotalBudget(messages []ChatMessage, maxTotalChars int) []ChatMessage { + if maxTotalChars <= 0 { + return messages + } + + total := 0 + keepFrom := 0 + for i := len(messages) - 1; i >= 0; i-- { + size := utf8.RuneCountInString(messages[i].Content) + + utf8.RuneCountInString(messages[i].ToolResult) + if total+size > maxTotalChars && i != len(messages)-1 { + keepFrom = i + 1 + break + } + total += size + } + + if keepFrom == 0 { + return messages + } + klog.V(2).Infof("AI conversation: dropped %d oldest message(s) to fit the %d-rune transcript budget", + keepFrom, maxTotalChars) + return messages[keepFrom:] +} + +// leakedToolCallPattern matches tool calls that a model previously emitted as +// text/XML instead of via the native tool-calling mechanism: full or partial +// / blocks (including the antml: namespace) and "[Tool: x]" +// summary markers. +var leakedToolCallPattern = regexp.MustCompile( + `(?is)<(?:antml:)?invoke\b.*?` + + `|]*>` + + `|\[Tool:[^\]]*\]`, +) + +// stripLeakedToolCalls removes textual/XML tool-call leakage from assistant +// content: whole ... blocks (with their parameter junk), +// stray invoke/parameter tags, and "[Tool: x]" markers. Surrounding prose is +// left intact. +func stripLeakedToolCalls(content string) string { + // Two-tier guard, behaviour-identical to running the regex unconditionally. + // Every alternative in the pattern needs a "<" or a "[", and beyond that the + // literal "invoke", "parameter", or "[tool:". Assistant turns carry YAML, + // code, and shell output where "<" alone is ubiquitous, so checking only for + // it sent nearly every message through a backtracking scan of the whole + // (up to maxAnthropicMessageChars) string. The fold is case-insensitive + // because the pattern is, and it only runs for candidates. + if !strings.ContainsAny(content, "<[") { + return content + } + lower := strings.ToLower(content) + if !strings.Contains(lower, "invoke") && + !strings.Contains(lower, "parameter") && + !strings.Contains(lower, "[tool:") { + return content + } + return leakedToolCallPattern.ReplaceAllString(content, "") +} + func summarizeScope(items []string) string { if len(items) == 0 { return "-" @@ -237,57 +449,70 @@ func buildRuntimePromptContext(c *gin.Context, cs *cluster.ClientSet) runtimePro return ctx } -// buildContextualSystemPrompt augments the system prompt with runtime/page context. -func buildContextualSystemPrompt(pageCtx *PageContext, runtimeCtx runtimePromptContext, language string) string { - prompt := systemPrompt +// contextualPromptSuffix returns the per-request/per-session context appended +// after the stable system prompt: current time, runtime context, page context, +// and response-language guidance. It is kept separate from the systemPrompt +// constant so the Anthropic path can cache the stable prefix while sending this +// volatile remainder uncached — a timestamp inside the cached block would +// invalidate the prompt cache on every request. +func contextualPromptSuffix(pageCtx *PageContext, runtimeCtx runtimePromptContext, language string) string { + var b strings.Builder - // Add current system time - prompt += fmt.Sprintf("\n\nCurrent system time: %s", time.Now().Format("2006-01-02 15:04:05 MST")) + // Current system time + fmt.Fprintf(&b, "\n\nCurrent system time: %s", time.Now().Format("2006-01-02 15:04:05 MST")) if runtimeCtx.ClusterName != "" || runtimeCtx.AccountName != "" || runtimeCtx.RBACOverview != "" { - prompt += "\n\nCurrent runtime context:" + b.WriteString("\n\nCurrent runtime context:") if runtimeCtx.ClusterName != "" { - prompt += fmt.Sprintf("\n- Current cluster: %s", runtimeCtx.ClusterName) + fmt.Fprintf(&b, "\n- Current cluster: %s", runtimeCtx.ClusterName) } if runtimeCtx.AccountName != "" { - prompt += fmt.Sprintf("\n- Current account name: %s", runtimeCtx.AccountName) + fmt.Fprintf(&b, "\n- Current account name: %s", runtimeCtx.AccountName) } if runtimeCtx.RBACOverview != "" { - prompt += fmt.Sprintf("\n- RBAC overview: %s", runtimeCtx.RBACOverview) + fmt.Fprintf(&b, "\n- RBAC overview: %s", runtimeCtx.RBACOverview) } } if pageCtx != nil { - prompt += "\n\nCurrent page context:" + b.WriteString("\n\nCurrent page context:") if pageCtx.Page != "" { - prompt += fmt.Sprintf("\n- User is viewing: %s", pageCtx.Page) + fmt.Fprintf(&b, "\n- User is viewing: %s", pageCtx.Page) } if pageCtx.ResourceKind != "" && pageCtx.ResourceName != "" { - prompt += fmt.Sprintf("\n- Current resource: %s/%s", pageCtx.ResourceKind, pageCtx.ResourceName) + fmt.Fprintf(&b, "\n- Current resource: %s/%s", pageCtx.ResourceKind, pageCtx.ResourceName) } if pageCtx.Namespace != "" { - prompt += fmt.Sprintf("\n- Current namespace: %s", pageCtx.Namespace) + fmt.Fprintf(&b, "\n- Current namespace: %s", pageCtx.Namespace) } // Add contextual suggestions switch pageCtx.Page { case "overview": - prompt += "\n- Suggest analyzing overall cluster health, resource utilization, and potential issues." + b.WriteString("\n- Suggest analyzing overall cluster health, resource utilization, and potential issues.") case "pod-detail": - prompt += "\n- Focus on this pod's status, logs, events, and health. Proactively check for issues." + b.WriteString("\n- Focus on this pod's status, logs, events, and health. Proactively check for issues.") case "deployment-detail": - prompt += "\n- Focus on this deployment's rollout status, replica health, and recent changes." + b.WriteString("\n- Focus on this deployment's rollout status, replica health, and recent changes.") case "node-detail": - prompt += "\n- Focus on this node's status, resource pressure, and pods running on it." + b.WriteString("\n- Focus on this node's status, resource pressure, and pods running on it.") } } if language == "zh" { - prompt += "\n\nResponse language:\n- Prefer replying in the same language as the user's latest message.\n- If the user's latest message language is unclear, respond in Simplified Chinese unless the user explicitly asks for another language." + b.WriteString("\n\nResponse language:\n- Prefer replying in the same language as the user's latest message.\n- If the user's latest message language is unclear, respond in Simplified Chinese unless the user explicitly asks for another language.") } else { - prompt += "\n\nResponse language:\n- Prefer replying in the same language as the user's latest message.\n- If the user's latest message language is unclear, respond in English unless the user explicitly asks for another language." + b.WriteString("\n\nResponse language:\n- Prefer replying in the same language as the user's latest message.\n- If the user's latest message language is unclear, respond in English unless the user explicitly asks for another language.") } + return b.String() +} + +// buildContextualSystemPrompt augments the system prompt with runtime/page +// context. Used by the OpenAI path (single system message); the Anthropic path +// caches systemPrompt and sends contextualPromptSuffix as a separate block. +func buildContextualSystemPrompt(pageCtx *PageContext, runtimeCtx runtimePromptContext, language string) string { + prompt := systemPrompt + contextualPromptSuffix(pageCtx, runtimeCtx, language) klog.V(4).Infof("system prompt %s", prompt) return prompt } @@ -357,6 +582,21 @@ func parseToolCallArguments(raw string) (map[string]interface{}, error) { return args, nil } +// toolArgsJSON marshals tool-call arguments to a JSON object string, defaulting +// to "{}" when the args are nil, empty, or marshal to null. It is the inverse of +// parseToolCallArguments and the single source of the empty-args convention +// shared by the OpenAI and Anthropic message builders. +func toolArgsJSON(args any) string { + raw, err := json.Marshal(args) + if err != nil { + return "{}" + } + if trimmed := strings.TrimSpace(string(raw)); trimmed != "" && trimmed != "null" { + return trimmed + } + return "{}" +} + type streamedToolCall struct { Index int64 ID string diff --git a/pkg/ai/agent_test.go b/pkg/ai/agent_test.go index 84fb4550..cf6ee7e6 100644 --- a/pkg/ai/agent_test.go +++ b/pkg/ai/agent_test.go @@ -12,12 +12,12 @@ import ( ) func TestNormalizeChatMessages(t *testing.T) { - longContent := strings.Repeat("a", maxMessageChars+10) - messages := make([]ChatMessage, 0, maxConversationMessages+2) + longContent := strings.Repeat("a", maxOpenAIMessageChars+10) + messages := make([]ChatMessage, 0, maxOpenAIConversationMessages+2) messages = append(messages, ChatMessage{Role: "user", Content: " "}) - for i := 0; i < maxConversationMessages+1; i++ { + for i := 0; i < maxOpenAIConversationMessages+1; i++ { content := " hello " - if i == maxConversationMessages { + if i == maxOpenAIConversationMessages { content = longContent } role := "user" @@ -27,9 +27,9 @@ func TestNormalizeChatMessages(t *testing.T) { messages = append(messages, ChatMessage{Role: role, Content: content}) } - normalized := normalizeChatMessages(messages) - if len(normalized) != maxConversationMessages { - t.Fatalf("expected %d messages, got %d", maxConversationMessages, len(normalized)) + normalized := normalizeChatMessages(messages, openAILimits) + if len(normalized) != maxOpenAIConversationMessages { + t.Fatalf("expected %d messages, got %d", maxOpenAIConversationMessages, len(normalized)) } if normalized[0].Content != "hello" { t.Fatalf("expected trimmed content, got %q", normalized[0].Content) @@ -37,8 +37,15 @@ func TestNormalizeChatMessages(t *testing.T) { if normalized[0].Role != "user" && normalized[0].Role != "assistant" { t.Fatalf("unexpected role: %s", normalized[0].Role) } - if len(normalized[len(normalized)-1].Content) != maxMessageChars { - t.Fatalf("expected truncated message length %d, got %d", maxMessageChars, len(normalized[len(normalized)-1].Content)) + last := normalized[len(normalized)-1].Content + // The notice is counted against the cap, so a truncated message lands + // exactly on it. An exact check also catches a regression that keeps far + // less than the budget allows, which an upper bound would let through. + if len([]rune(last)) != maxOpenAIMessageChars { + t.Fatalf("truncated message must be exactly %d runes, got %d", maxOpenAIMessageChars, len([]rune(last))) + } + if !strings.HasSuffix(last, truncationNotice) { + t.Fatalf("truncated message must carry the truncation notice, got tail %q", last[max(0, len(last)-80):]) } } diff --git a/pkg/ai/anthropic.go b/pkg/ai/anthropic.go index 72b0b735..10038312 100644 --- a/pkg/ai/anthropic.go +++ b/pkg/ai/anthropic.go @@ -7,19 +7,145 @@ import ( anthropic "github.com/anthropics/anthropic-sdk-go" "github.com/gin-gonic/gin" + "github.com/zxh326/kite/pkg/model" "k8s.io/klog/v2" ) -func toAnthropicMessages(chatMessages []ChatMessage) []anthropic.MessageParam { - normalized := normalizeChatMessages(chatMessages) - messages := make([]anthropic.MessageParam, 0, len(normalized)) +func toolUseInput(args map[string]interface{}) any { + if args == nil { + return map[string]interface{}{} + } + return args +} + +// legacyAnthropicModelMarkers identifies models that predate the Opus-4.6-era +// request surface and 400 on output effort / adaptive thinking / context +// management. Markers are matched as substrings against a separator-normalized +// ID (see normalizeAnthropicModelID), so dotted gateway aliases +// ("claude-3.5-sonnet") and Vertex dated snapshots ("claude-sonnet-4@20250514") +// match the same markers as the first-party hyphen form. "haiku" is deliberately +// broad: a future Haiku is more likely to follow the small-model tier, and being +// wrong there costs a missed optimization rather than a failed request. +var legacyAnthropicModelMarkers = []string{ + "opus-4-5", "opus-4-1", "opus-4-0", "opus-4-20", + "sonnet-4-5", "sonnet-4-0", "sonnet-4-20", + "claude-3-", "claude-2-", "claude-v2", "claude-v1", "claude-instant", "haiku", +} + +// normalizeAnthropicModelID folds the separator variants the same model ID +// appears under across first-party, Bedrock, Vertex, and OpenAI-compatible +// gateways ('.', '@', ':', '/', '_') to '-', so one marker matches all of them. +// A trailing "-latest"/"-v1"-style suffix is left alone; markers are prefixes of +// the version segment, not anchored at the end. +func normalizeAnthropicModelID(modelName string) string { + m := strings.ToLower(strings.TrimSpace(modelName)) + for _, sep := range []string{".", "@", ":", "/", "_"} { + m = strings.ReplaceAll(m, sep, "-") + } + return m +} + +// anthropicModelSupportsModernFeatures reports whether the configured model +// accepts the Opus-4.6-era request surface: output effort, adaptive thinking, +// and context management. This is a deny list on purpose — an allow list of +// known-good version strings silently misses every future model, and falling +// back to the plain shape keeps a small max_tokens while the server still spends +// it on thinking. Unknown/newer Claude models therefore get the modern shape. +func anthropicModelSupportsModernFeatures(modelName string) bool { + m := normalizeAnthropicModelID(modelName) + // Non-Claude identifiers (empty, OpenAI-compatible gateway names) get the + // plain shape: nothing guarantees they accept the Anthropic beta surface. + if !strings.Contains(m, "claude") && !strings.Contains(m, "fable") && !strings.Contains(m, "mythos") { + return false + } + for _, marker := range legacyAnthropicModelMarkers { + if strings.Contains(m, marker) { + return false + } + } + return true +} + +// anthropicOutputEffort maps the persisted effort setting onto the SDK constant. +// Effort is the depth knob on the modern request surface: budget_tokens was +// removed and 400s, so this is the only way to ask for more thinking. All five +// levels are accepted on current models. +func anthropicOutputEffort(effort string) anthropic.BetaOutputConfigEffort { + switch model.NormalizeGeneralAIEffort(effort) { + case model.GeneralAIEffortLow: + return anthropic.BetaOutputConfigEffortLow + case model.GeneralAIEffortMedium: + return anthropic.BetaOutputConfigEffortMedium + case model.GeneralAIEffortHigh: + return anthropic.BetaOutputConfigEffortHigh + case model.GeneralAIEffortMax: + return anthropic.BetaOutputConfigEffortMax + default: + return anthropic.BetaOutputConfigEffortXhigh + } +} + +// emptyAnthropicResponseMessage explains an empty response using the provider's +// own stop_reason. A bare "AI returned no content" hides the two causes an +// operator can actually act on — an exhausted output budget and an exceeded +// context window — behind a message that reads like a Kite bug. +func emptyAnthropicResponseMessage(stopReason anthropic.BetaStopReason, maxTokens int) string { + switch stopReason { + case anthropic.BetaStopReasonMaxTokens: + return fmt.Sprintf("The model hit the Max Tokens limit (%d) before producing an answer. "+ + "On current Claude models thinking and answer share this budget — raise Max Tokens, "+ + "or lower Reasoning Effort, in Settings.", maxTokens) + case anthropic.BetaStopReasonModelContextWindowExceeded: + return "The conversation exceeded the model's context window. Start a new chat, " + + "or narrow the tool queries so less output is carried forward." + case anthropic.BetaStopReasonRefusal: + return "The model declined this request." + case anthropic.BetaStopReasonPauseTurn: + return "The model paused mid-turn without producing content. Send the message again to resume." + default: + if stopReason != "" { + return fmt.Sprintf("AI returned no content (stop_reason: %s)", stopReason) + } + return "AI returned no content" + } +} + +func toAnthropicMessages(chatMessages []ChatMessage) []anthropic.BetaMessageParam { + normalized := normalizeChatMessages(chatMessages, anthropicLimits) + + // Append blocks under a role, coalescing into the previous message when it + // shares that role. Anthropic requires strictly alternating user/assistant + // roles, so an assistant text turn immediately followed by an assistant + // tool_use turn must merge into one message. + messages := make([]anthropic.BetaMessageParam, 0, len(normalized)) + push := func(role string, blocks ...anthropic.BetaContentBlockParamUnion) { + if n := len(messages); n > 0 && string(messages[n-1].Role) == role { + messages[n-1].Content = append(messages[n-1].Content, blocks...) + return + } + if role == "assistant" { + messages = append(messages, anthropic.BetaMessageParam{Role: "assistant", Content: blocks}) + } else { + messages = append(messages, anthropic.NewBetaUserMessage(blocks...)) + } + } for _, msg := range normalized { switch msg.Role { case "assistant": - messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content))) + push("assistant", anthropic.NewBetaTextBlock(msg.Content)) + case "tool": + // A tool round-trip expands to an assistant tool_use block followed + // by a user tool_result block — the structured pair the model needs, + // never flattened text. + push("assistant", anthropic.BetaContentBlockParamUnion{OfToolUse: &anthropic.BetaToolUseBlockParam{ + ID: msg.ToolCallID, + Name: msg.ToolName, + Input: toolUseInput(msg.ToolArgs), + }}) + push("user", anthropic.NewBetaToolResultBlock(msg.ToolCallID, msg.ToolResult, msg.IsError)) default: - messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content))) + push("user", anthropic.NewBetaTextBlock(msg.Content)) } } @@ -33,9 +159,11 @@ func (a *Agent) processChatAnthropic(c *gin.Context, req *ChatRequest, sendEvent if language == "" { language = "en" } - sysPrompt := buildContextualSystemPrompt(req.PageContext, runtimeCtx, language) + // The stable systemPrompt is cached inside runAnthropicConversation; only the + // volatile per-request context travels here, as a separate uncached block. + sysPromptSuffix := contextualPromptSuffix(req.PageContext, runtimeCtx, language) messages := toAnthropicMessages(req.Messages) - a.runAnthropicConversation(ctx, c, sysPrompt, messages, sendEvent) + a.runAnthropicConversation(ctx, c, sysPromptSuffix, messages, sendEvent) } func (a *Agent) continueChatAnthropic(c *gin.Context, session pendingSession, sendEvent func(SSEEvent)) error { @@ -46,6 +174,7 @@ func (a *Agent) continueChatAnthropic(c *gin.Context, session pendingSession, se func (a *Agent) continueChatAnthropicWithToolResult(c *gin.Context, session pendingSession, result string, isError bool, sendEvent func(SSEEvent)) error { ctx := c.Request.Context() + result = truncateWithNotice(result, maxAnthropicToolResultChars, "tool result "+session.ToolCall.Name) sendEvent(SSEEvent{ Event: "tool_result", Data: buildToolResultEventData(session.ToolCall.ID, session.ToolCall.Name, result, isError), @@ -58,8 +187,8 @@ func (a *Agent) continueChatAnthropicWithToolResult(c *gin.Context, session pend session.AnthropicMessages = append( session.AnthropicMessages, - anthropic.NewUserMessage( - anthropic.NewToolResultBlock(session.ToolCall.ID, toolResult, isError), + anthropic.NewBetaUserMessage( + anthropic.NewBetaToolResultBlock(session.ToolCall.ID, toolResult, isError), ), ) a.runAnthropicConversation(ctx, c, session.SystemPrompt, session.AnthropicMessages, sendEvent) @@ -69,24 +198,76 @@ func (a *Agent) continueChatAnthropicWithToolResult(c *gin.Context, session pend func (a *Agent) runAnthropicConversation( ctx context.Context, c *gin.Context, - sysPrompt string, - messages []anthropic.MessageParam, + sysPromptSuffix string, + messages []anthropic.BetaMessageParam, sendEvent func(SSEEvent), ) { - tools := AnthropicToolDefs(a.cs) + if len(messages) == 0 { + sendEvent(SSEEvent{Event: "error", Data: map[string]string{"message": "No conversation messages to send"}}) + return + } + + tools := BetaAnthropicToolDefs(a.cs) + modern := anthropicModelSupportsModernFeatures(a.model) + + // Cache the large, fixed system prompt together with the tool definitions + // that render before it — a stable prefix multi-turn conversations read at + // ~0.1x price. The volatile per-request context (time, cluster, page) rides + // in a second, uncached block so it can't invalidate the cached prefix. + system := []anthropic.BetaTextBlockParam{{ + Text: systemPrompt, + CacheControl: anthropic.NewBetaCacheControlEphemeralParam(), + }} + if strings.TrimSpace(sysPromptSuffix) != "" { + system = append(system, anthropic.BetaTextBlockParam{Text: sysPromptSuffix}) + } + + // max_tokens is sent as configured. It is a ceiling on thinking + answer + // combined, but an unused ceiling costs nothing, so silently raising an + // operator's explicit budget would only inflate their bill. Depth is asked + // for with output effort below, not by inflating this number. + maxTokens := a.maxTokens maxIterations := 100 for i := 0; i < maxIterations; i++ { - stream := a.anthropicClient.Messages.NewStreaming(ctx, anthropic.MessageNewParams{ + params := anthropic.BetaMessageNewParams{ Model: a.model, Messages: messages, - System: []anthropic.TextBlockParam{{Text: sysPrompt}}, + System: system, Tools: tools, - MaxTokens: int64(a.maxTokens), - ToolChoice: anthropic.ToolChoiceUnionParam{ - OfAuto: &anthropic.ToolChoiceAutoParam{}, + MaxTokens: int64(maxTokens), + ToolChoice: anthropic.BetaToolChoiceUnionParam{ + // Serialize tool calls: the confirmation/pause-resume flow carries + // one pending tool per turn, and parallel tool_use in a single + // assistant turn would split its tool_results across two user + // messages on resume — an invalid, alternation-breaking request. + OfAuto: &anthropic.BetaToolChoiceAutoParam{ + DisableParallelToolUse: anthropic.Bool(true), + }, }, - }) + } + + if modern { + // Opus 4.x request surface — older models (e.g. claude-sonnet-4-5) + // 400 on these, so apply them only when the model supports them. + // display:"summarized" keeps the streamed think content populated; + // the default "omitted" would blank out the UI's thinking bubble. + params.Thinking = anthropic.BetaThinkingConfigParamUnion{OfAdaptive: &anthropic.BetaThinkingConfigAdaptiveParam{ + Display: anthropic.BetaThinkingConfigAdaptiveDisplaySummarized, + }} + params.OutputConfig = anthropic.BetaOutputConfigParam{Effort: anthropicOutputEffort(a.effort)} + // Context editing: server-side clears the oldest tool results once the + // transcript grows large, keeping long agent loops within budget + // without summarizing. No-op on gateways that don't honor the beta. + params.ContextManagement = anthropic.BetaContextManagementConfigParam{ + Edits: []anthropic.BetaContextManagementConfigEditUnionParam{ + {OfClearToolUses20250919: &anthropic.BetaClearToolUses20250919EditParam{}}, + }, + } + params.Betas = []anthropic.AnthropicBeta{anthropic.AnthropicBetaContextManagement2025_06_27} + } + + stream := a.anthropicClient.Beta.Messages.NewStreaming(ctx, params) message, messageContent, thinkingContent, streamedToolCalls, err := consumeAnthropicStreamingResponse(stream, sendEvent) if err != nil { @@ -95,17 +276,22 @@ func (a *Agent) runAnthropicConversation( return } + klog.V(2).Infof("Anthropic usage: input=%d cache_read=%d cache_write=%d output=%d", + message.Usage.InputTokens, message.Usage.CacheReadInputTokens, message.Usage.CacheCreationInputTokens, message.Usage.OutputTokens) + if len(streamedToolCalls) == 0 { content := strings.TrimSpace(messageContent) if content == "" && strings.TrimSpace(thinkingContent) == "" { - sendEvent(SSEEvent{Event: "error", Data: map[string]string{"message": "AI returned no content"}}) + sendEvent(SSEEvent{Event: "error", Data: map[string]string{ + "message": emptyAnthropicResponseMessage(message.StopReason, maxTokens), + }}) return } return } messages = append(messages, message.ToParam()) - toolResults := make([]anthropic.ContentBlockParamUnion, 0, len(streamedToolCalls)) + toolResults := make([]anthropic.BetaContentBlockParamUnion, 0, len(streamedToolCalls)) for _, tc := range streamedToolCalls { toolName := tc.Name @@ -113,7 +299,7 @@ func (a *Agent) runAnthropicConversation( if err != nil { klog.Errorf("Failed to parse tool arguments: %v", err) toolError := fmt.Sprintf("Failed to parse arguments: %v", err) - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+toolError, true)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, "Tool error: "+toolError, true)) continue } @@ -130,17 +316,17 @@ func (a *Agent) runAnthropicConversation( Event: "tool_result", Data: buildToolResultEventData(tc.ID, toolName, result, true), }) - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+result, true)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, "Tool error: "+result, true)) continue } if len(toolResults) > 0 { - messages = append(messages, anthropic.NewUserMessage(toolResults...)) + messages = append(messages, anthropic.NewBetaUserMessage(toolResults...)) toolResults = nil } sessionID := agentPendingSessions.save(pendingSession{ Provider: a.provider, - SystemPrompt: sysPrompt, - AnthropicMessages: append([]anthropic.MessageParam(nil), messages...), + SystemPrompt: sysPromptSuffix, + AnthropicMessages: append([]anthropic.BetaMessageParam(nil), messages...), ToolCall: pendingToolCall{ ID: tc.ID, Name: toolName, @@ -149,7 +335,7 @@ func (a *Agent) runAnthropicConversation( }) if sessionID == "" { errorMsg := "Failed to save pending session" - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+errorMsg, true)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, "Tool error: "+errorMsg, true)) continue } sendEvent(SSEEvent{ @@ -166,16 +352,16 @@ func (a *Agent) runAnthropicConversation( Event: "tool_result", Data: buildToolResultEventData(tc.ID, toolName, result, true), }) - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+result, true)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, "Tool error: "+result, true)) continue } if len(toolResults) > 0 { - messages = append(messages, anthropic.NewUserMessage(toolResults...)) + messages = append(messages, anthropic.NewBetaUserMessage(toolResults...)) } sessionID := agentPendingSessions.save(pendingSession{ Provider: a.provider, - SystemPrompt: sysPrompt, - AnthropicMessages: append([]anthropic.MessageParam(nil), messages...), + SystemPrompt: sysPromptSuffix, + AnthropicMessages: append([]anthropic.BetaMessageParam(nil), messages...), ToolCall: pendingToolCall{ ID: tc.ID, Name: toolName, @@ -184,7 +370,7 @@ func (a *Agent) runAnthropicConversation( }) if sessionID == "" { errorMsg := "Failed to save pending session" - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, "Tool error: "+errorMsg, true)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, "Tool error: "+errorMsg, true)) continue } sendEvent(SSEEvent{ @@ -195,6 +381,12 @@ func (a *Agent) runAnthropicConversation( } result, isError := ExecuteTool(ctx, c, a.cs, toolName, args) + // Cap the live result the same way replayed history is capped. The + // per-tool bounds (log bytes, item counts) do not cover every tool — + // get_resource yaml-marshals whole objects — so without this an + // oversized result is only trimmed on the *next* turn, after the + // oversized request has already been sent. + result = truncateWithNotice(result, maxAnthropicToolResultChars, "tool result "+toolName) sendEvent(SSEEvent{ Event: "tool_result", @@ -204,11 +396,11 @@ func (a *Agent) runAnthropicConversation( if isError { result = "Tool error: " + result } - toolResults = append(toolResults, anthropic.NewToolResultBlock(tc.ID, result, isError)) + toolResults = append(toolResults, anthropic.NewBetaToolResultBlock(tc.ID, result, isError)) } if len(toolResults) > 0 { - messages = append(messages, anthropic.NewUserMessage(toolResults...)) + messages = append(messages, anthropic.NewBetaUserMessage(toolResults...)) } } @@ -218,41 +410,41 @@ func (a *Agent) runAnthropicConversation( func consumeAnthropicStreamingResponse( stream interface { Next() bool - Current() anthropic.MessageStreamEventUnion + Current() anthropic.BetaRawMessageStreamEventUnion Err() error Close() error }, sendEvent func(SSEEvent), -) (anthropic.Message, string, string, []streamedToolCall, error) { +) (anthropic.BetaMessage, string, string, []streamedToolCall, error) { defer func() { if err := stream.Close(); err != nil { klog.Warningf("Failed to close AI stream: %v", err) } }() - var message anthropic.Message + var message anthropic.BetaMessage var contentBuilder strings.Builder var thinkingBuilder strings.Builder for stream.Next() { event := stream.Current() if err := message.Accumulate(event); err != nil { - return anthropic.Message{}, "", "", nil, err + return anthropic.BetaMessage{}, "", "", nil, err } - if startEvent, ok := event.AsAny().(anthropic.ContentBlockStartEvent); ok { - if thinkingBlock, ok := startEvent.ContentBlock.AsAny().(anthropic.ThinkingBlock); ok && thinkingBlock.Thinking != "" { + if startEvent, ok := event.AsAny().(anthropic.BetaRawContentBlockStartEvent); ok { + if thinkingBlock, ok := startEvent.ContentBlock.AsAny().(anthropic.BetaThinkingBlock); ok && thinkingBlock.Thinking != "" { thinkingBuilder.WriteString(thinkingBlock.Thinking) sendEvent(SSEEvent{Event: "think", Data: map[string]string{"content": thinkingBlock.Thinking}}) } } - if deltaEvent, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok { - if textDelta, ok := deltaEvent.Delta.AsAny().(anthropic.TextDelta); ok && textDelta.Text != "" { + if deltaEvent, ok := event.AsAny().(anthropic.BetaRawContentBlockDeltaEvent); ok { + if textDelta, ok := deltaEvent.Delta.AsAny().(anthropic.BetaTextDelta); ok && textDelta.Text != "" { contentBuilder.WriteString(textDelta.Text) sendEvent(SSEEvent{Event: "message", Data: map[string]string{"content": textDelta.Text}}) } - if thinkingDelta, ok := deltaEvent.Delta.AsAny().(anthropic.ThinkingDelta); ok && thinkingDelta.Thinking != "" { + if thinkingDelta, ok := deltaEvent.Delta.AsAny().(anthropic.BetaThinkingDelta); ok && thinkingDelta.Thinking != "" { thinkingBuilder.WriteString(thinkingDelta.Thinking) sendEvent(SSEEvent{Event: "think", Data: map[string]string{"content": thinkingDelta.Thinking}}) } @@ -260,7 +452,7 @@ func consumeAnthropicStreamingResponse( } if err := stream.Err(); err != nil { - return anthropic.Message{}, "", "", nil, err + return anthropic.BetaMessage{}, "", "", nil, err } toolCalls := anthropicToolCallsToStreamedToolCalls(message) @@ -276,31 +468,27 @@ func consumeAnthropicStreamingResponse( return message, content, thinking, toolCalls, nil } -func anthropicToolCallsToStreamedToolCalls(message anthropic.Message) []streamedToolCall { +func anthropicToolCallsToStreamedToolCalls(message anthropic.BetaMessage) []streamedToolCall { toolCalls := make([]streamedToolCall, 0) for idx, block := range message.Content { - toolUse, ok := block.AsAny().(anthropic.ToolUseBlock) + toolUse, ok := block.AsAny().(anthropic.BetaToolUseBlock) if !ok { continue } - arguments := strings.TrimSpace(string(toolUse.Input)) - if arguments == "" || arguments == "null" { - arguments = "{}" - } toolCalls = append(toolCalls, streamedToolCall{ Index: int64(idx), ID: toolUse.ID, Name: toolUse.Name, - Arguments: arguments, + Arguments: toolArgsJSON(toolUse.Input), }) } return toolCalls } -func anthropicMessageText(message anthropic.Message) string { +func anthropicMessageText(message anthropic.BetaMessage) string { var contentBuilder strings.Builder for _, block := range message.Content { - textBlock, ok := block.AsAny().(anthropic.TextBlock) + textBlock, ok := block.AsAny().(anthropic.BetaTextBlock) if !ok || textBlock.Text == "" { continue } @@ -309,10 +497,10 @@ func anthropicMessageText(message anthropic.Message) string { return contentBuilder.String() } -func anthropicMessageThinking(message anthropic.Message) string { +func anthropicMessageThinking(message anthropic.BetaMessage) string { var thinkingBuilder strings.Builder for _, block := range message.Content { - thinkingBlock, ok := block.AsAny().(anthropic.ThinkingBlock) + thinkingBlock, ok := block.AsAny().(anthropic.BetaThinkingBlock) if !ok || thinkingBlock.Thinking == "" { continue } diff --git a/pkg/ai/config.go b/pkg/ai/config.go index 5f91d06a..f45c8a82 100644 --- a/pkg/ai/config.go +++ b/pkg/ai/config.go @@ -18,6 +18,7 @@ type RuntimeConfig struct { APIKey string BaseURL string MaxTokens int + Effort string } func normalizeProvider(provider string) string { @@ -54,12 +55,13 @@ func LoadRuntimeConfig() (*RuntimeConfig, error) { APIKey: strings.TrimSpace(string(setting.AIAPIKey)), BaseURL: strings.TrimSpace(setting.AIBaseURL), MaxTokens: setting.AIMaxTokens, + Effort: model.NormalizeGeneralAIEffort(setting.AIEffort), } if cfg.Model == "" { cfg.Model = defaultModelForProvider(cfg.Provider) } if cfg.MaxTokens <= 0 { - cfg.MaxTokens = 4096 + cfg.MaxTokens = model.DefaultGeneralAIMaxTokensByProvider(cfg.Provider) } if !cfg.Enabled { return cfg, nil diff --git a/pkg/ai/conversation_history_test.go b/pkg/ai/conversation_history_test.go new file mode 100644 index 00000000..05a4e141 --- /dev/null +++ b/pkg/ai/conversation_history_test.go @@ -0,0 +1,332 @@ +package ai + +import ( + "strings" + "testing" + "unicode/utf8" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/zxh326/kite/pkg/model" +) + +// TestToAnthropicMessagesRebuildsToolRoundTrip verifies the core fix: a tool +// turn in the history is rebuilt into structured tool_use + tool_result blocks +// (not flattened to text), roles stay alternating, and any tool-call text a +// previous broken turn leaked into assistant content is stripped. +func TestToAnthropicMessagesRebuildsToolRoundTrip(t *testing.T) { + history := []ChatMessage{ + {Role: "user", Content: "find pod nginx"}, + {Role: "assistant", Content: "Let me check."}, + { + Role: "tool", + ToolCallID: "toolu_1", + ToolName: "get_resource", + ToolArgs: map[string]interface{}{"kind": "Pod", "name": "nginx"}, + ToolResult: "status: Running", + }, + {Role: "assistant", Content: "It is running. [Tool: get_resource] Pod"}, + {Role: "user", Content: "is it healthy?"}, + } + + msgs := toAnthropicMessages(history) + + var sawToolUse, sawToolResult bool + for _, m := range msgs { + for _, b := range m.Content { + if b.OfToolUse != nil { + sawToolUse = true + if b.OfToolUse.ID != "toolu_1" || b.OfToolUse.Name != "get_resource" { + t.Fatalf("unexpected tool_use: id=%q name=%q", b.OfToolUse.ID, b.OfToolUse.Name) + } + } + if b.OfToolResult != nil { + sawToolResult = true + if b.OfToolResult.ToolUseID != "toolu_1" { + t.Fatalf("tool_result tool_use_id mismatch: %q", b.OfToolResult.ToolUseID) + } + } + if b.OfText != nil { + if strings.Contains(b.OfText.Text, " toolCap { + t.Fatalf("tool result must respect its own cap %d, got %d", toolCap, n) + } + if !strings.HasSuffix(m.ToolResult, truncationNotice) { + t.Fatalf("truncated tool result must carry the notice") + } + } + } + if !sawUser { + t.Fatal("user message within its budget must pass through unmodified") + } + if !sawTool { + t.Fatal("tool round-trip was dropped") + } +} + +func TestTruncateWithNoticeMarksCutContent(t *testing.T) { + long := strings.Repeat("x", 500) + out := truncateWithNotice(long, 100, "test content") + if len([]rune(out)) > 100 { + t.Fatalf("result must stay within the cap, got %d runes", len([]rune(out))) + } + if !strings.HasSuffix(out, truncationNotice) { + t.Fatalf("truncated content must carry the notice, got %q", out) + } + // Content that fits is returned byte-identical, with no notice appended. + if got := truncateWithNotice("short", 100, "test content"); got != "short" { + t.Fatalf("expected unchanged content, got %q", got) + } + // Multi-byte content whose BYTE length exceeds the cap but whose RUNE count + // does not must pass through untouched — len(s) alone would over-report it. + fits := strings.Repeat("中", 50) + if got := truncateWithNotice(fits, 100, "test content"); got != fits { + t.Fatalf("multi-byte content within the rune cap must be unchanged, got %d runes", len([]rune(got))) + } + // Appending the notice must not split the kept prefix mid-rune. + cn := strings.Repeat("中", 200) + cnOut := truncateWithNotice(cn, 120, "test content") + if !utf8.ValidString(cnOut) { + t.Fatalf("truncation produced invalid UTF-8") + } + if got := truncateWithNotice("abc", 0, "test content"); got != "" { + t.Fatalf("expected empty for max<=0, got %q", got) + } +} + +func TestTrimToTotalBudgetDropsOldestMessages(t *testing.T) { + // Per-message caps cannot bound a request; the aggregate budget is what the + // provider's context window actually limits. + body := strings.Repeat("x", 1000) + msgs := []ChatMessage{ + {Role: "user", Content: body}, + {Role: "assistant", Content: body}, + {Role: "user", Content: body}, + } + + out := trimToTotalBudget(msgs, 2500) + if len(out) != 2 { + t.Fatalf("expected the oldest message dropped, got %d messages", len(out)) + } + total := 0 + for _, m := range out { + total += len([]rune(m.Content)) + } + if total > 2500 { + t.Fatalf("transcript must fit the budget, got %d runes", total) + } + + // A transcript already within budget is returned untouched. + if got := trimToTotalBudget(msgs, 100000); len(got) != len(msgs) { + t.Fatalf("expected all %d messages kept, got %d", len(msgs), len(got)) + } + + // The newest turn is never dropped, even when it alone exceeds the budget — + // dropping it would send a request with no current question. + if got := trimToTotalBudget(msgs, 10); len(got) != 1 { + t.Fatalf("expected the newest message retained, got %d", len(got)) + } + + // Tool results count against the budget, not just Content. + toolMsgs := []ChatMessage{ + {Role: "tool", ToolCallID: "t1", ToolName: "x", ToolResult: body}, + {Role: "tool", ToolCallID: "t2", ToolName: "x", ToolResult: body}, + {Role: "user", Content: "now"}, + } + if got := trimToTotalBudget(toolMsgs, 1500); len(got) != 2 { + t.Fatalf("tool results must count toward the budget, got %d messages", len(got)) + } +} + +func TestStripLeakedToolCalls(t *testing.T) { + in := "before [Tool: get_resource] 1 after" + out := stripLeakedToolCalls(in) + if strings.Contains(out, " maxListedResourceItems + if truncated { + items = items[:maxListedResourceItems] + } + + for _, item := range items { name := item.GetName() ns := item.GetNamespace() creationTime := item.GetCreationTimestamp().Format("2006-01-02 15:04:05") @@ -181,6 +201,19 @@ func executeListResources(ctx context.Context, cs *cluster.ClientSet, args map[s sb.WriteString("\n") } + if truncated { + klog.V(2).Infof("list_resources: truncated %s listing from %d to %d items", resource.Kind, total, maxListedResourceItems) + // Only suggest a namespace when one would actually narrow the result. + // normalizeNamespace clears it for cluster-scoped kinds, so advising it + // for Nodes sends the model into an identical retry. + narrowing := "label_selector" + if !resource.ClusterScoped { + narrowing = "namespace or label_selector" + } + fmt.Fprintf(&sb, "\n[Only the first %d of %d items are shown. Narrow the query with %s to see the rest.]\n", + maxListedResourceItems, total, narrowing) + } + return sb.String(), false } @@ -463,14 +496,48 @@ func asInt64(v interface{}) (int64, bool) { } } +// Pod log bounds. maxPodLogBytes is what the model receives. maxPodLogTailLines +// bounds the window requested from the kubelet: tail_lines is sent server-side, +// so an unclamped value makes the kubelet seek back through the whole log file +// before any byte cap stops the read. It is also what the tool schema advertises, +// so the model plans against the bound that is actually enforced. +// +// maxPodLogReadBytes bounds the transfer. It is deliberately larger than +// maxPodLogBytes because the API serves logs oldest-first: to return the NEWEST +// bytes of the window (where a crash trace lives) the tail has to be reachable, +// which a cap equal to the output would truncate away. +const ( + maxPodLogBytes = 32 * 1024 + maxPodLogReadBytes = 8 * maxPodLogBytes + maxPodLogTailLines = 2000 + defaultPodLogTail = 100 +) + func executeGetPodLogs(ctx context.Context, cs *cluster.ClientSet, args map[string]interface{}) (string, bool) { name, _ := args["name"].(string) namespace, _ := args["namespace"].(string) container, _ := args["container"].(string) - tailLines := int64(100) + tailLines := int64(defaultPodLogTail) + tailClamped := false if tl, ok := args["tail_lines"].(float64); ok { - tailLines = int64(tl) + // Range-check while the value is still a float64. Converting an + // out-of-range or NaN float to int64 is implementation-defined in Go + // (amd64 yields MinInt64, arm64 saturates to MaxInt64), so clamping + // after the conversion would give a platform-dependent window. + switch { + case math.IsNaN(tl) || tl < 1: + tailLines = 1 + tailClamped = true + case tl > maxPodLogTailLines: + tailLines = maxPodLogTailLines + tailClamped = true + default: + tailLines = int64(tl) + } + if tailClamped { + klog.V(2).Infof("get_pod_logs: clamped requested tail_lines %v to %d", tl, tailLines) + } } previous, _ := args["previous"].(bool) @@ -478,9 +545,13 @@ func executeGetPodLogs(ctx context.Context, cs *cluster.ClientSet, args map[stri return "Error: name and namespace are required", true } + limitBytes := int64(maxPodLogReadBytes) logOpts := &corev1.PodLogOptions{ TailLines: &tailLines, Previous: previous, + // Bound the transfer server-side so the kubelet does not stream a + // multi-megabyte window that is discarded on arrival. + LimitBytes: &limitBytes, } if container != "" { logOpts.Container = container @@ -497,16 +568,48 @@ func executeGetPodLogs(ctx context.Context, cs *cluster.ClientSet, args map[stri } }() - logBytes, err := io.ReadAll(io.LimitReader(stream, 32*1024)) // 32KB limit + logBytes, err := io.ReadAll(io.LimitReader(stream, maxPodLogReadBytes)) if err != nil { return fmt.Sprintf("Error reading logs: %v", err), true } + // The API applies TailLines first, then LimitBytes from the START of that + // window — so hitting the read bound means the NEWEST bytes were dropped + // server-side, the opposite direction from the client-side cut below. + serverCapped := len(logBytes) >= maxPodLogReadBytes + truncated := len(logBytes) > maxPodLogBytes + if truncated { + // Logs arrive oldest-first, so keep the TAIL: the model asked for recent + // lines and a crash trace sits at the end of the window. Cutting the head + // can land mid-rune, so advance to the next rune boundary instead of + // emitting an invalid leading sequence. + logBytes = logBytes[len(logBytes)-maxPodLogBytes:] + for len(logBytes) > 0 && !utf8.RuneStart(logBytes[0]) { + logBytes = logBytes[1:] + } + } if len(logBytes) == 0 { return fmt.Sprintf("No logs available for pod %s/%s", namespace, name), false } - return fmt.Sprintf("Logs for pod %s/%s:\n\n```\n%s\n```", namespace, name, string(logBytes)), false + notice := "" + if truncated { + klog.V(2).Infof("get_pod_logs: truncated %s/%s logs at %d bytes (serverCapped=%v)", namespace, name, maxPodLogBytes, serverCapped) + if serverCapped { + notice = fmt.Sprintf("\n[The requested window exceeded %d KB, so it was cut at both ends: the newest lines were dropped by the API and only %d KB of what remained is shown. Use a smaller tail_lines, or filter by container, to read a specific section.]", + maxPodLogReadBytes/1024, maxPodLogBytes/1024) + } else { + notice = fmt.Sprintf("\n[Older lines were dropped: only the most recent %d KB of the requested window is shown. Use a smaller tail_lines, or filter by container, to read an earlier section.]", + maxPodLogBytes/1024) + } + } + if tailClamped { + // State the clamp explicitly. The schema's maximum is advisory and + // providers do not reliably enforce it, so without this the model can + // conclude an error is absent from a window it never actually received. + notice += fmt.Sprintf("\n[tail_lines was clamped to %d, the maximum this tool serves.]", tailLines) + } + return fmt.Sprintf("Logs for pod %s/%s:\n\n```\n%s\n```%s", namespace, name, logBytes, notice), false } func executeGetClusterOverview(ctx context.Context, cs *cluster.ClientSet) (string, bool) { diff --git a/pkg/ai/tool_resource_execution_test.go b/pkg/ai/tool_resource_execution_test.go index 042369fa..8093b231 100644 --- a/pkg/ai/tool_resource_execution_test.go +++ b/pkg/ai/tool_resource_execution_test.go @@ -1,15 +1,100 @@ package ai import ( + "context" + "fmt" "math" "reflect" "strings" "testing" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/zxh326/kite/pkg/cluster" + "github.com/zxh326/kite/pkg/kube" ) +// newFakeClientSet builds a ClientSet backed by a fake client using the same +// scheme the production client uses, so a test never drifts from the real one. +func newFakeClientSet(objs ...client.Object) *cluster.ClientSet { + c := fake.NewClientBuilder().WithScheme(kube.GetScheme()).WithObjects(objs...).Build() + return &cluster.ClientSet{K8sClient: &kube.K8sClient{Client: c}} +} + +func TestExecuteListResourcesCapsItemCount(t *testing.T) { + // Comfortably past the cap, so the truncation branch is exercised. + total := maxListedResourceItems + 25 + objs := make([]client.Object, 0, total) + for i := 0; i < total; i++ { + objs = append(objs, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("cm-%03d", i), Namespace: "default"}, + }) + } + cs := newFakeClientSet(objs...) + + out, isErr := executeListResources(context.Background(), cs, + map[string]interface{}{"kind": "ConfigMap", "namespace": "default"}) + if isErr { + t.Fatalf("unexpected tool error: %s", out) + } + + // The header still reports the true total, so the model is not misled about + // how many objects exist — only the rendered list is cut. + if !strings.Contains(out, fmt.Sprintf("Found %d ConfigMap(s)", total)) { + t.Fatalf("header must report the real total %d, got:\n%s", total, out[:min(300, len(out))]) + } + if n := strings.Count(out, "\n- "); n != maxListedResourceItems { + t.Fatalf("expected %d rendered items, got %d", maxListedResourceItems, n) + } + if !strings.Contains(out, "Narrow the query") { + t.Fatal("truncated listing must tell the model how to see the rest") + } +} + +func TestExecuteListResourcesNoCapNoticeWhenUnderLimit(t *testing.T) { + cs := newFakeClientSet( + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "only", Namespace: "default"}}, + ) + + out, isErr := executeListResources(context.Background(), cs, + map[string]interface{}{"kind": "ConfigMap", "namespace": "default"}) + if isErr { + t.Fatalf("unexpected tool error: %s", out) + } + if strings.Contains(out, "Narrow the query") { + t.Fatalf("a listing under the cap must not carry the truncation notice:\n%s", out) + } +} + +func TestPodLogSchemaMatchesEnforcedBounds(t *testing.T) { + // The schema is what the model plans against; if it drifts from the code's + // clamp the model asks for a window it silently never receives. + var props map[string]any + for _, def := range toolDefinitions(nil) { + if def.Name == "get_pod_logs" { + props = def.Properties + break + } + } + if props == nil { + t.Fatal("get_pod_logs definition not found") + } + tail, ok := props["tail_lines"].(map[string]any) + if !ok { + t.Fatalf("tail_lines property missing: %#v", props) + } + if got := tail["maximum"]; got != maxPodLogTailLines { + t.Fatalf("schema maximum %v must equal enforced clamp %d", got, maxPodLogTailLines) + } + if got := tail["minimum"]; got != 1 { + t.Fatalf("schema minimum should be 1, got %v", got) + } +} + func TestObjectToYAML(t *testing.T) { if got := objectToYAML(nil); got != "" { t.Fatalf("expected empty string for nil object, got %q", got) @@ -36,9 +121,10 @@ func TestRedactSensitiveResourceData(t *testing.T) { tests := []struct { name string resource resourceInfo + redacted bool }{ - {name: "secret", resource: resourceInfo{Kind: "Secret"}}, - {name: "configmap", resource: resourceInfo{Kind: "ConfigMap"}}, + {name: "secret", resource: resourceInfo{Kind: "Secret"}, redacted: true}, + {name: "configmap", resource: resourceInfo{Kind: "ConfigMap"}, redacted: false}, } for _, tc := range tests { @@ -62,8 +148,8 @@ func TestRedactSensitiveResourceData(t *testing.T) { for _, key := range []string{"data", "stringData", "binaryData"} { raw := obj.Object[key].(map[string]interface{}) for field, value := range raw { - if value != "***" { - t.Fatalf("expected %s.%s to be redacted, got %#v", key, field, value) + if (value == "***") != tc.redacted { + t.Fatalf("%s.%s: want redacted=%v, got %#v", key, field, tc.redacted, value) } } } diff --git a/pkg/ai/tools.go b/pkg/ai/tools.go index 85232e18..cf3acc88 100644 --- a/pkg/ai/tools.go +++ b/pkg/ai/tools.go @@ -1,6 +1,8 @@ package ai import ( + "fmt" + anthropic "github.com/anthropics/anthropic-sdk-go" "github.com/openai/openai-go" "github.com/openai/openai-go/shared" @@ -116,7 +118,7 @@ func toolDefinitions(cs *cluster.ClientSet) []agentToolDefinition { }, { Name: "list_resources", - Description: "List Kubernetes resources of a given kind, optionally filtered by namespace and label selector. Returns a summary of matching resources.", + Description: fmt.Sprintf("List Kubernetes resources of a given kind, optionally filtered by namespace and label selector. Returns a summary of matching resources, capped at %d items — narrow the query with namespace or label_selector when more are expected.", maxListedResourceItems), Properties: map[string]any{ "kind": map[string]any{ "type": "string", @@ -151,7 +153,9 @@ func toolDefinitions(cs *cluster.ClientSet) []agentToolDefinition { }, "tail_lines": map[string]any{ "type": "integer", - "description": "Number of recent log lines to retrieve. Defaults to 100.", + "minimum": 1, + "maximum": maxPodLogTailLines, + "description": fmt.Sprintf("Number of recent log lines to retrieve. Defaults to 100, maximum %d. Output is additionally capped at %d KB; request a smaller window to read a specific section.", maxPodLogTailLines, maxPodLogBytes/1024), }, "previous": map[string]any{ "type": "boolean", @@ -308,21 +312,23 @@ func OpenAIToolDefs(cs *cluster.ClientSet) []openai.ChatCompletionToolParam { return tools } -func AnthropicToolDefs(cs *cluster.ClientSet) []anthropic.ToolUnionParam { +// BetaAnthropicToolDefs builds tool definitions for the Beta Messages API, +// which the Anthropic path uses so it can enable context-management (context +// editing) alongside tool use. +func BetaAnthropicToolDefs(cs *cluster.ClientSet) []anthropic.BetaToolUnionParam { defs := toolDefinitions(cs) - tools := make([]anthropic.ToolUnionParam, 0, len(defs)) + tools := make([]anthropic.BetaToolUnionParam, 0, len(defs)) for _, def := range defs { - tool := anthropic.ToolParam{ + tool := anthropic.BetaToolParam{ Name: def.Name, Description: anthropic.String(def.Description), - InputSchema: anthropic.ToolInputSchemaParam{ - Type: "object", + InputSchema: anthropic.BetaToolInputSchemaParam{ Properties: def.Properties, Required: def.Required, }, } - tools = append(tools, anthropic.ToolUnionParam{OfTool: &tool}) + tools = append(tools, anthropic.BetaToolUnionParam{OfTool: &tool}) } return tools diff --git a/pkg/model/general_setting.go b/pkg/model/general_setting.go index 74a47c90..771c6332 100644 --- a/pkg/model/general_setting.go +++ b/pkg/model/general_setting.go @@ -12,14 +12,76 @@ import ( ) const DefaultGeneralAIModel = "gpt-4o-mini" -const DefaultGeneralAnthropicModel = "claude-sonnet-4-5" +const DefaultGeneralAnthropicModel = "claude-opus-5" const DefaultGeneralKubectlImage = "zzde/kubectl:latest" const DefaultGeneralNodeTerminalImage = "busybox:latest" +// Default max_tokens per provider. On current Claude models max_tokens is a +// ceiling on thinking + answer combined, and adaptive thinking spends from the +// same budget, so a small value truncates the answer mid-sentence. It is a +// ceiling, not an allocation — an unused ceiling costs nothing. gpt-4o-mini +// caps output at 16384, so the two providers cannot share one default. +// +// Depth is controlled by AIEffort (output_config.effort), not by max_tokens. +const ( + DefaultGeneralOpenAIMaxTokens = 8192 + DefaultGeneralAnthropicMaxTokens = 64000 + // DefaultGeneralAIMaxTokens is the provider-agnostic fallback, kept at the + // smaller value so an unknown provider never overshoots its model's cap. + DefaultGeneralAIMaxTokens = DefaultGeneralOpenAIMaxTokens +) + +// AI output effort levels, passed straight through as output_config.effort. +// This is the depth knob on current Claude models: budget_tokens was removed +// and returns 400, so effort is the only way to ask for more thinking. +const ( + GeneralAIEffortLow = "low" + GeneralAIEffortMedium = "medium" + GeneralAIEffortHigh = "high" + GeneralAIEffortXHigh = "xhigh" + GeneralAIEffortMax = "max" + + // DefaultGeneralAIEffort follows the guidance for agentic and coding work on + // current Claude models, which is what the Kubernetes agent loop is. + DefaultGeneralAIEffort = GeneralAIEffortXHigh +) + +// GeneralAIEfforts lists the accepted effort levels, weakest first. +var GeneralAIEfforts = []string{ + GeneralAIEffortLow, + GeneralAIEffortMedium, + GeneralAIEffortHigh, + GeneralAIEffortXHigh, + GeneralAIEffortMax, +} + +// NormalizeGeneralAIEffort maps an operator-supplied value onto a known level, +// falling back to the default rather than rejecting the save. +func NormalizeGeneralAIEffort(effort string) string { + normalized := strings.ToLower(strings.TrimSpace(effort)) + for _, valid := range GeneralAIEfforts { + if normalized == valid { + return normalized + } + } + return DefaultGeneralAIEffort +} + const GeneralAIProviderOpenAI = "openai" const GeneralAIProviderAnthropic = "anthropic" const DefaultGeneralAIProvider = GeneralAIProviderOpenAI +// DefaultGeneralAIMaxTokensByProvider mirrors DefaultGeneralAIModelByProvider: +// the default token budget follows the default model of the same provider. +func DefaultGeneralAIMaxTokensByProvider(provider string) int { + switch NormalizeGeneralAIProvider(provider) { + case GeneralAIProviderAnthropic: + return DefaultGeneralAnthropicMaxTokens + default: + return DefaultGeneralOpenAIMaxTokens + } +} + func DefaultGeneralNodeTerminalImageValue() string { image := strings.TrimSpace(common.NodeTerminalImage) if image == "" { @@ -35,7 +97,8 @@ type GeneralSetting struct { AIModel string `json:"aiModel" gorm:"column:ai_model;type:varchar(255);not null;default:'gpt-4o-mini'"` AIAPIKey SecretString `json:"aiApiKey" gorm:"column:ai_api_key;type:text"` AIBaseURL string `json:"aiBaseUrl" gorm:"column:ai_base_url;type:varchar(500)"` - AIMaxTokens int `json:"aiMaxTokens" gorm:"column:ai_max_tokens;type:integer;default:4096"` + AIMaxTokens int `json:"aiMaxTokens" gorm:"column:ai_max_tokens;type:integer;default:64000"` + AIEffort string `json:"aiEffort" gorm:"column:ai_effort;type:varchar(20);not null;default:'xhigh'"` KubectlEnabled bool `json:"kubectlEnabled" gorm:"column:kubectl_enabled;type:boolean;not null;default:true"` KubectlImage string `json:"kubectlImage" gorm:"column:kubectl_image;type:varchar(255);not null;default:'zzde/kubectl:latest'"` NodeTerminalImage string `json:"nodeTerminalImage" gorm:"column:node_terminal_image;type:varchar(255);not null;default:'busybox:latest'"` @@ -91,6 +154,11 @@ func GetGeneralSetting() (*GeneralSetting, error) { setting.AIModel = DefaultGeneralAIModelByProvider(setting.AIProvider) updates["ai_model"] = setting.AIModel } + if normalizedEffort := NormalizeGeneralAIEffort(setting.AIEffort); setting.AIEffort != normalizedEffort { + // Covers both an upgraded install (empty column) and a stale value. + setting.AIEffort = normalizedEffort + updates["ai_effort"] = normalizedEffort + } if setting.KubectlImage == "" { setting.KubectlImage = DefaultGeneralKubectlImage updates["kubectl_image"] = DefaultGeneralKubectlImage @@ -120,7 +188,8 @@ func GetGeneralSetting() (*GeneralSetting, error) { AIAgentEnabled: false, AIProvider: DefaultGeneralAIProvider, AIModel: DefaultGeneralAIModel, - AIMaxTokens: 4096, + AIMaxTokens: DefaultGeneralAIMaxTokensByProvider(DefaultGeneralAIProvider), + AIEffort: DefaultGeneralAIEffort, KubectlEnabled: true, KubectlImage: DefaultGeneralKubectlImage, NodeTerminalImage: DefaultGeneralNodeTerminalImageValue(), diff --git a/pkg/model/general_setting_upgrade_test.go b/pkg/model/general_setting_upgrade_test.go new file mode 100644 index 00000000..25a41b03 --- /dev/null +++ b/pkg/model/general_setting_upgrade_test.go @@ -0,0 +1,137 @@ +package model + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/zxh326/kite/pkg/common" +) + +// legacyGeneralSetting mirrors the general_settings schema as it existed before +// the ai_effort column was introduced. AutoMigrate is the only migration +// mechanism in this project, so an upgrade is exactly "old table, new struct". +type legacyGeneralSetting struct { + Model + AIAgentEnabled bool `gorm:"column:ai_agent_enabled;type:boolean;not null;default:false"` + AIProvider string `gorm:"column:ai_provider;type:varchar(50);not null;default:'openai'"` + AIModel string `gorm:"column:ai_model;type:varchar(255);not null;default:'gpt-4o-mini'"` + AIMaxTokens int `gorm:"column:ai_max_tokens;type:integer;default:4096"` + KubectlEnabled bool `gorm:"column:kubectl_enabled;type:boolean;not null;default:true"` + KubectlImage string `gorm:"column:kubectl_image;type:varchar(255);not null;default:'zzde/kubectl:latest'"` + NodeTerminalImage string `gorm:"column:node_terminal_image;type:varchar(255);not null;default:'busybox:latest'"` +} + +func (legacyGeneralSetting) TableName() string { return "general_settings" } + +func newUpgradeTestDB(t *testing.T) *gorm.DB { + t.Helper() + common.KiteEncryptKey = "general-setting-upgrade-test-key" + common.JwtSecret = "general-setting-upgrade-test-jwt" + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("opening test database: %v", err) + } + return db +} + +// TestAutoMigrateAddsAIEffortToExistingInstall is the check I had skipped: that +// AutoMigrate actually adds ai_effort to a pre-existing table, and that a row +// written before the column existed reads back with a usable value rather than +// an empty string that would reach the provider as an invalid effort. +func TestAutoMigrateAddsAIEffortToExistingInstall(t *testing.T) { + db := newUpgradeTestDB(t) + + // 1. An install running the old schema, with a row already in it. + if err := db.AutoMigrate(&legacyGeneralSetting{}); err != nil { + t.Fatalf("migrating legacy schema: %v", err) + } + if db.Migrator().HasColumn(&GeneralSetting{}, "ai_effort") { + t.Fatal("legacy schema unexpectedly already has ai_effort") + } + legacy := legacyGeneralSetting{ + Model: Model{ID: 1}, + AIProvider: GeneralAIProviderAnthropic, + AIModel: DefaultGeneralAnthropicModel, + AIMaxTokens: 4096, + KubectlEnabled: true, + KubectlImage: DefaultGeneralKubectlImage, + NodeTerminalImage: DefaultGeneralNodeTerminalImage, + } + if err := db.Create(&legacy).Error; err != nil { + t.Fatalf("seeding legacy row: %v", err) + } + + // 2. The upgraded binary migrates the same table. + if err := db.AutoMigrate(&GeneralSetting{}); err != nil { + t.Fatalf("migrating to current schema: %v", err) + } + if !db.Migrator().HasColumn(&GeneralSetting{}, "ai_effort") { + t.Fatal("ai_effort column was not added by AutoMigrate") + } + + // 3. The pre-existing row must survive and resolve to a valid effort. GORM + // backfills a NOT NULL column with its default, but the value that + // actually reaches the provider is whatever GetGeneralSetting returns. + DB = db + setting, err := GetGeneralSetting() + if err != nil { + t.Fatalf("reading setting after upgrade: %v", err) + } + if setting.AIModel != DefaultGeneralAnthropicModel { + t.Fatalf("upgrade lost the configured model: %q", setting.AIModel) + } + if setting.AIEffort != DefaultGeneralAIEffort { + t.Fatalf("ai_effort after upgrade = %q, want %q", setting.AIEffort, DefaultGeneralAIEffort) + } + + // 4. The backfill must be persisted, not just applied in memory — otherwise + // every read re-runs it and a direct DB consumer still sees the old value. + var persisted string + if err := db.Raw("SELECT ai_effort FROM general_settings WHERE id = 1").Scan(&persisted).Error; err != nil { + t.Fatalf("reading persisted ai_effort: %v", err) + } + if persisted != DefaultGeneralAIEffort { + t.Fatalf("persisted ai_effort = %q, want %q", persisted, DefaultGeneralAIEffort) + } +} + +// TestGetGeneralSettingNormalizesStoredEffort covers a hand-edited or +// downgrade-then-upgrade row carrying a value the SDK would reject. +func TestGetGeneralSettingNormalizesStoredEffort(t *testing.T) { + db := newUpgradeTestDB(t) + if err := db.AutoMigrate(&GeneralSetting{}); err != nil { + t.Fatalf("migrating: %v", err) + } + DB = db + + for _, stored := range []string{"", " ", "extreme", "HIGH"} { + if err := db.Exec("DELETE FROM general_settings").Error; err != nil { + t.Fatalf("clearing table: %v", err) + } + if err := db.Exec( + "INSERT INTO general_settings (id, ai_provider, ai_model, ai_effort, kubectl_image, node_terminal_image) VALUES (1, ?, ?, ?, ?, ?)", + GeneralAIProviderAnthropic, DefaultGeneralAnthropicModel, stored, + DefaultGeneralKubectlImage, DefaultGeneralNodeTerminalImage, + ).Error; err != nil { + t.Fatalf("seeding row with effort %q: %v", stored, err) + } + + setting, err := GetGeneralSetting() + if err != nil { + t.Fatalf("reading setting with stored effort %q: %v", stored, err) + } + want := NormalizeGeneralAIEffort(stored) + if setting.AIEffort != want { + t.Fatalf("stored effort %q resolved to %q, want %q", stored, setting.AIEffort, want) + } + // "HIGH" is a valid level in the wrong case: it must normalize to "high", + // not fall back to the default, or an operator's choice is silently lost. + if stored == "HIGH" && setting.AIEffort != GeneralAIEffortHigh { + t.Fatalf("uppercase HIGH resolved to %q, want %q", setting.AIEffort, GeneralAIEffortHigh) + } + } +} diff --git a/pkg/settings/handler.go b/pkg/settings/handler.go index 8e3f5cb4..d4132dbd 100644 --- a/pkg/settings/handler.go +++ b/pkg/settings/handler.go @@ -24,6 +24,7 @@ func HandleGetGeneralSetting(c *gin.Context) { "aiApiKeyConfigured": hasAIAPIKey, "aiBaseUrl": setting.AIBaseURL, "aiMaxTokens": setting.AIMaxTokens, + "aiEffort": model.NormalizeGeneralAIEffort(setting.AIEffort), "kubectlEnabled": setting.KubectlEnabled, "kubectlImage": setting.KubectlImage, "nodeTerminalImage": setting.NodeTerminalImage, @@ -43,6 +44,7 @@ type UpdateGeneralSettingRequest struct { AIAPIKey *string `json:"aiApiKey"` AIBaseURL *string `json:"aiBaseUrl"` AIMaxTokens *int `json:"aiMaxTokens"` + AIEffort *string `json:"aiEffort"` KubectlEnabled *bool `json:"kubectlEnabled"` KubectlImage *string `json:"kubectlImage"` NodeTerminalImage *string `json:"nodeTerminalImage"` @@ -131,7 +133,15 @@ func HandleUpdateGeneralSetting(c *gin.Context) { //nolint:gocyclo aiMaxTokens = *req.AIMaxTokens } if aiMaxTokens <= 0 { - aiMaxTokens = 4096 + aiMaxTokens = model.DefaultGeneralAIMaxTokensByProvider(aiProvider) + } + // No upper bound: max_tokens is a provider-side ceiling and only the provider + // knows the configured model's real limit. Rejecting a large value here would + // cap a model the operator deliberately chose for its bigger output budget. + + aiEffort := model.NormalizeGeneralAIEffort(currentSetting.AIEffort) + if req.AIEffort != nil { + aiEffort = model.NormalizeGeneralAIEffort(*req.AIEffort) } updates := map[string]interface{}{} @@ -150,6 +160,9 @@ func HandleUpdateGeneralSetting(c *gin.Context) { //nolint:gocyclo if req.AIMaxTokens != nil { updates["ai_max_tokens"] = aiMaxTokens } + if req.AIEffort != nil { + updates["ai_effort"] = aiEffort + } if req.KubectlEnabled != nil { updates["kubectl_enabled"] = kubectlEnabled } @@ -196,6 +209,7 @@ func HandleUpdateGeneralSetting(c *gin.Context) { //nolint:gocyclo "aiApiKeyConfigured": hasAIAPIKey, "aiBaseUrl": updated.AIBaseURL, "aiMaxTokens": updated.AIMaxTokens, + "aiEffort": model.NormalizeGeneralAIEffort(updated.AIEffort), "kubectlEnabled": updated.KubectlEnabled, "kubectlImage": updated.KubectlImage, "nodeTerminalImage": updated.NodeTerminalImage, diff --git a/ui/src/components/ai-chat/ai-chat-messages.tsx b/ui/src/components/ai-chat/ai-chat-messages.tsx index 1cab1330..f2a5c8a6 100644 --- a/ui/src/components/ai-chat/ai-chat-messages.tsx +++ b/ui/src/components/ai-chat/ai-chat-messages.tsx @@ -399,7 +399,7 @@ function MessageBubble({ )} {hasContent && ( -
+
{message.content} diff --git a/ui/src/components/ai-chat/ai-chat-types.ts b/ui/src/components/ai-chat/ai-chat-types.ts index c6578fc1..c06ef5b3 100644 --- a/ui/src/components/ai-chat/ai-chat-types.ts +++ b/ui/src/components/ai-chat/ai-chat-types.ts @@ -64,7 +64,20 @@ export interface ChatSession { clusterName?: string } -export type APIChatMessage = { role: 'user' | 'assistant'; content: string } +// Wire format sent to POST /api/v1/ai/chat. Tool turns are sent structurally +// (not flattened to "[Tool: ...]" text) so the backend can rebuild real +// tool_use / tool_result blocks — feeding tool calls back as plain text +// poisons the model into emitting textual/XML tool calls on later turns. +export type APIChatMessage = + | { role: 'user' | 'assistant'; content: string } + | { + role: 'tool' + tool_call_id: string + tool_name: string + tool_args?: Record + tool_result: string + is_error?: boolean + } export interface AIChatState { messages: ChatMessage[] diff --git a/ui/src/components/settings/general-management.tsx b/ui/src/components/settings/general-management.tsx index 43b153e3..537bdbb1 100644 --- a/ui/src/components/settings/general-management.tsx +++ b/ui/src/components/settings/general-management.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { + AIEffort, GeneralSettingUpdateRequest, updateGeneralSetting, useGeneralSetting, @@ -30,7 +31,22 @@ import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' const DEFAULT_MODEL = 'gpt-4o-mini' -const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-5' +const DEFAULT_ANTHROPIC_MODEL = 'claude-opus-5' +// Mirrors DefaultGeneralOpenAIMaxTokens / DefaultGeneralAnthropicMaxTokens in +// pkg/model/general_setting.go. The default must fit the provider's default +// model output ceiling: gpt-4o-mini caps at 16384, current Claude models reach +// 128K and spend part of the budget on thinking tokens. +const DEFAULT_OPENAI_MAX_TOKENS = 8192 +const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000 +const defaultMaxTokensForProvider = (provider: 'openai' | 'anthropic') => + provider === 'anthropic' + ? DEFAULT_ANTHROPIC_MAX_TOKENS + : DEFAULT_OPENAI_MAX_TOKENS +// Mirrors DefaultGeneralAIEffort / GeneralAIEfforts in the same Go file. Effort +// is the depth knob on current Claude models — budget_tokens was removed — and +// xhigh is the recommended level for agentic work. +const DEFAULT_AI_EFFORT: AIEffort = 'xhigh' +const AI_EFFORTS: AIEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'] const DEFAULT_KUBECTL_IMAGE = 'zzde/kubectl:latest' const DEFAULT_NODE_TERMINAL_IMAGE = 'busybox:latest' @@ -42,6 +58,7 @@ interface GeneralSettingsFormData { aiApiKeyConfigured: boolean aiBaseUrl: string aiMaxTokens: number + aiEffort: AIEffort kubectlEnabled: boolean kubectlImage: string nodeTerminalImage: string @@ -61,7 +78,8 @@ export function GeneralManagement() { aiApiKey: '', aiApiKeyConfigured: false, aiBaseUrl: '', - aiMaxTokens: 4096, + aiMaxTokens: DEFAULT_OPENAI_MAX_TOKENS, + aiEffort: DEFAULT_AI_EFFORT, kubectlEnabled: true, kubectlImage: DEFAULT_KUBECTL_IMAGE, nodeTerminalImage: DEFAULT_NODE_TERMINAL_IMAGE, @@ -79,7 +97,10 @@ export function GeneralManagement() { aiApiKey: '', aiApiKeyConfigured: data.aiApiKeyConfigured ?? false, aiBaseUrl: data.aiBaseUrl || '', - aiMaxTokens: data.aiMaxTokens || 4096, + aiMaxTokens: + data.aiMaxTokens || + defaultMaxTokensForProvider(data.aiProvider || 'openai'), + aiEffort: data.aiEffort || DEFAULT_AI_EFFORT, kubectlEnabled: data.kubectlEnabled ?? true, kubectlImage: data.kubectlImage || DEFAULT_KUBECTL_IMAGE, nodeTerminalImage: data.nodeTerminalImage || DEFAULT_NODE_TERMINAL_IMAGE, @@ -157,7 +178,10 @@ export function GeneralManagement() { aiProvider: formData.aiProvider, aiModel: formData.aiModel.trim() || defaultModel, aiBaseUrl: formData.aiBaseUrl.trim(), - aiMaxTokens: formData.aiMaxTokens || 4096, + aiMaxTokens: + formData.aiMaxTokens || + defaultMaxTokensForProvider(formData.aiProvider), + aiEffort: formData.aiEffort, kubectlEnabled: formData.kubectlEnabled, kubectlImage: formData.kubectlImage.trim() || DEFAULT_KUBECTL_IMAGE, nodeTerminalImage: @@ -321,17 +345,61 @@ export function GeneralManagement() { id="general-ai-max-tokens" type="number" min="1" - max="128000" - value={formData.aiMaxTokens} + value={formData.aiMaxTokens || ''} onChange={(e) => setFormData((prev) => ({ ...prev, - aiMaxTokens: parseInt(e.target.value) || 4096, + // Keep an empty field empty. Substituting the default here + // makes the box jump while the user is still typing, so the + // fallback is applied on save instead. + aiMaxTokens: parseInt(e.target.value) || 0, })) } - placeholder="4096" + placeholder={String( + defaultMaxTokensForProvider(formData.aiProvider) + )} /> +

+ {t( + 'generalManagement.aiAgent.form.maxTokensHint', + 'Ceiling on a single response. On Claude models thinking and answer share this budget, so a small value truncates the answer.' + )} +

+ + {formData.aiProvider === 'anthropic' && ( +
+ + +

+ {t( + 'generalManagement.aiAgent.form.effortHint', + 'How much the model reasons and how many tools it uses. Higher costs more tokens; xhigh suits cluster troubleshooting.' + )} +

+
+ )}
)} diff --git a/ui/src/hooks/use-ai-chat.ts b/ui/src/hooks/use-ai-chat.ts index b48dbfd4..1b4dd88f 100644 --- a/ui/src/hooks/use-ai-chat.ts +++ b/ui/src/hooks/use-ai-chat.ts @@ -497,9 +497,23 @@ export function useAIChat() { for (const message of messagesRef.current) { if (message.role === 'user' || message.role === 'assistant') { history.push({ role: message.role, content: message.content }) - } else if (message.role === 'tool' && message.toolResult) { - const toolSummary = `[Tool: ${message.toolName}]\nResult: ${message.toolResult}` - history.push({ role: 'assistant', content: toolSummary }) + } else if ( + message.role === 'tool' && + message.toolCallId && + message.toolResult + ) { + // Send the tool round-trip structurally. The backend rebuilds a real + // tool_use + tool_result pair from this. Tool messages without a + // result (denied/cancelled/pending) are skipped to avoid a dangling + // tool_use with no matching tool_result. + history.push({ + role: 'tool', + tool_call_id: message.toolCallId, + tool_name: message.toolName ?? '', + tool_args: message.toolArgs, + tool_result: message.toolResult, + is_error: message.actionStatus === 'error', + }) } } diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 70df6d85..d3705291 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -762,7 +762,10 @@ "apiKey": "API Key", "apiKeyPlaceholder": "Leave empty to keep current API Key", "baseUrl": "Base URL", - "maxTokens": "Max Tokens" + "maxTokens": "Max Tokens", + "maxTokensHint": "Ceiling on a single response. On Claude models thinking and answer share this budget, so a small value truncates the answer.", + "effort": "Reasoning Effort", + "effortHint": "How much the model reasons and how many tools it uses. Higher costs more tokens; xhigh suits cluster troubleshooting." } }, "kubectl": { diff --git a/ui/src/i18n/locales/zh.json b/ui/src/i18n/locales/zh.json index 1a6448c1..aef0cfb9 100644 --- a/ui/src/i18n/locales/zh.json +++ b/ui/src/i18n/locales/zh.json @@ -773,7 +773,10 @@ "apiKey": "API Key", "apiKeyPlaceholder": "留空则保持当前 API Key 不变", "baseUrl": "Base URL", - "maxTokens": "最大 Token 数" + "maxTokens": "最大 Token 数", + "maxTokensHint": "单次回复的上限。Claude 模型的思考与回答共用这个预算,设得太小会让回答中途截断。", + "effort": "推理强度", + "effortHint": "决定模型思考多深、调用多少工具。越高消耗的 Token 越多;集群排障场景建议 xhigh。" } }, "kubectl": { diff --git a/ui/src/lib/api/admin.ts b/ui/src/lib/api/admin.ts index eda3aad5..b7f94b07 100644 --- a/ui/src/lib/api/admin.ts +++ b/ui/src/lib/api/admin.ts @@ -341,6 +341,8 @@ export interface APIKeyCreateRequest { name: string } +export type AIEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' + export interface GeneralSetting { aiAgentEnabled: boolean aiProvider: 'openai' | 'anthropic' @@ -349,6 +351,7 @@ export interface GeneralSetting { aiApiKeyConfigured: boolean aiBaseUrl: string aiMaxTokens: number + aiEffort: AIEffort kubectlEnabled: boolean kubectlImage: string nodeTerminalImage: string @@ -367,6 +370,7 @@ export interface GeneralSettingUpdateRequest { aiApiKey?: string aiBaseUrl?: string aiMaxTokens?: number + aiEffort?: AIEffort kubectlEnabled?: boolean kubectlImage?: string nodeTerminalImage?: string diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 113eb41d..a13c7a87 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -292,6 +292,10 @@ body { border: 1px solid var(--border); padding: 0.3em 0.6em; text-align: left; + /* Break long unbreakable tokens (image refs, URLs, pod names) so a single + cell can't force the table wider than its container. The wrapper's + overflow-x:auto is the fallback when there are simply too many columns. */ + overflow-wrap: anywhere; } .ai-markdown th {