mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e5806ade8 | |||
| 50f586ec8f | |||
| 8a8e684dff | |||
| 7ef99ac60f | |||
| a67f514560 | |||
| b6bb35cb71 | |||
| 4e82fac442 | |||
| 5ec2217b0f | |||
| 8a851723ba | |||
| 53b628c5f8 | |||
| e1c94cb362 | |||
| ecf95b52e1 | |||
| 0641c92acc | |||
| 3bb20f5283 | |||
| 633fa38b2b | |||
| f905cee48c | |||
| 182c10ea1a | |||
| fcaa52bf1c | |||
| 7e6455732c | |||
| 71301a9035 |
@@ -13,6 +13,8 @@
|
||||
// - No channels in maps (Yaegi panics on range over map[string]chan)
|
||||
// - All ctx.* calls guarded with nil checks
|
||||
// - Simple data structures only
|
||||
// - The extension runner serializes handler calls per-extension, so
|
||||
// concurrent subagent events cannot race on this shared state.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -43,7 +45,8 @@ const (
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state - all simple types
|
||||
// Package-level state — safe because the runner serializes all handler
|
||||
// invocations for the same extension (per-extension reentrant mutex).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
@@ -282,8 +285,8 @@ func Init(api ext.API) {
|
||||
|
||||
submonPushWidget()
|
||||
|
||||
// Remove the entry immediately (no goroutine to avoid races)
|
||||
newEntries := submonEntries[:0]
|
||||
// Remove the entry — build a new slice to avoid aliasing bugs
|
||||
newEntries := make([]*submonEntry, 0, len(submonEntries))
|
||||
for _, en := range submonEntries {
|
||||
if en.callID != e.ToolCallID {
|
||||
newEntries = append(newEntries, en)
|
||||
|
||||
@@ -18,7 +18,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
## Features
|
||||
|
||||
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
|
||||
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, subagent - no MCP overhead
|
||||
- **Built-in Core Tools**: bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent - no MCP overhead
|
||||
- **Smart @ Attachments**: Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri`
|
||||
- **MCP Integration**: Connect external MCP servers for expanded capabilities
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
@@ -126,8 +126,13 @@ model: anthropic/claude-sonnet-latest
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
thinking-level: off # off, minimal, low, medium, high
|
||||
```
|
||||
|
||||
All of the above keys can also be set programmatically via the SDK
|
||||
(`kit.Options.MaxTokens`, `Options.Temperature`, `Options.ThinkingLevel`, etc.)
|
||||
without touching config files — see [SDK options](#with-options).
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
@@ -187,7 +192,7 @@ mcpServers:
|
||||
--no-prompt-templates Disable prompt template loading
|
||||
|
||||
# Generation parameters
|
||||
--max-tokens Maximum tokens in response (default: 4096)
|
||||
--max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits)
|
||||
--temperature Randomness 0.0-1.0 (default: 0.7)
|
||||
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
|
||||
--top-k Limit top K tokens (default: 40)
|
||||
@@ -541,6 +546,20 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
Streaming: true,
|
||||
Quiet: true,
|
||||
|
||||
// Generation parameters (override env/config/per-model defaults)
|
||||
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → 8192 floor)
|
||||
ThinkingLevel: "medium", // "off", "low", "medium", "high"
|
||||
Temperature: ptr(float32(0.2)), // pointer so 0.0 != unset; nil = provider default
|
||||
TopP: nil, // nil = leave provider/per-model default
|
||||
TopK: nil,
|
||||
FrequencyPenalty: nil,
|
||||
PresencePenalty: nil,
|
||||
|
||||
// Provider configuration (override env/config without reaching into viper)
|
||||
ProviderAPIKey: "sk-...", // "" = use config / provider env var
|
||||
ProviderURL: "https://proxy.internal/v1", // "" = provider default
|
||||
TLSSkipVerify: false, // only takes effect when true
|
||||
|
||||
// Session options
|
||||
SessionPath: "./session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
@@ -561,6 +580,46 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
**Generation & provider fields** (added in v0.55+) let SDK consumers configure
|
||||
Kit entirely in-code without `viper.Set()` workarounds or shipping a `.kit.yml`.
|
||||
Precedence is `Options` > `KIT_*` env vars > `.kit.yml` > per-model defaults
|
||||
(`modelSettings` / `customModels`) > provider-level defaults. Sampling params
|
||||
are pointer types so explicit `0.0` is distinguishable from "leave alone"; a
|
||||
non-zero `MaxTokens` suppresses automatic right-sizing the same way `--max-tokens`
|
||||
does on the CLI.
|
||||
|
||||
### MCP OAuth (remote MCP servers)
|
||||
|
||||
When a remote MCP server returns 401, Kit runs the full OAuth flow (dynamic
|
||||
client registration → PKCE → token exchange → persistence) but delegates the
|
||||
user-facing step — showing the authorization URL and receiving the callback —
|
||||
to an `MCPAuthHandler` that you pass explicitly via `Options.MCPAuthHandler`.
|
||||
If nil, OAuth is disabled and the authorization-required error surfaces to the
|
||||
caller; the SDK never auto-opens a browser or binds a localhost port.
|
||||
|
||||
```go
|
||||
// CLI/TUI apps: opens the system browser + prints status to stderr.
|
||||
authHandler, _ := kit.NewCLIMCPAuthHandler()
|
||||
defer authHandler.Close()
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
|
||||
// Custom UX: reuse the SDK's port + callback server, supply your own
|
||||
// presentation via OnAuthURL (TUI modal, QR code, web redirect, etc.).
|
||||
// h, _ := kit.NewDefaultMCPAuthHandler()
|
||||
// h.OnAuthURL = func(server, authURL string) { myUI.Show(server, authURL) }
|
||||
//
|
||||
// Full control (web apps, daemons): implement kit.MCPAuthHandler yourself —
|
||||
// no localhost binding, no side effects.
|
||||
```
|
||||
|
||||
Tokens are persisted to `$XDG_CONFIG_HOME/.kit/mcp_tokens.json` by default; swap
|
||||
in a custom `MCPTokenStoreFactory` for encrypted, DB-backed, or in-memory
|
||||
storage. See the [SDK options docs](/sdk/options#mcp-oauth-authorization) for
|
||||
the full matrix.
|
||||
|
||||
### Custom Tools
|
||||
|
||||
Create custom tools with automatic schema generation — no external dependencies needed:
|
||||
|
||||
+1
-1
@@ -297,7 +297,7 @@ func init() {
|
||||
flags.BoolVar(&noPromptTemplates, "no-prompt-templates", false, "disable prompt template discovery")
|
||||
|
||||
// Model generation parameters
|
||||
flags.IntVar(&maxTokens, "max-tokens", 4096, "maximum number of tokens in the response")
|
||||
flags.IntVar(&maxTokens, "max-tokens", 8192, "maximum number of output tokens per response (auto-raised up to 32768 for models with higher known output limits; see internal/models/embedded_models.json)")
|
||||
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
||||
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
|
||||
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
|
||||
|
||||
@@ -130,6 +130,58 @@ func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_ConcurrentSubagents verifies no panics when multiple
|
||||
// subagents emit events concurrently from different goroutines.
|
||||
func TestSubagentMonitor_ConcurrentSubagents(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Start 5 subagents concurrently
|
||||
done := make(chan struct{}, 5)
|
||||
for i := range 5 {
|
||||
go func(idx int) {
|
||||
defer func() { done <- struct{}{} }()
|
||||
|
||||
callID := fmt.Sprintf("concurrent-%d", idx)
|
||||
task := fmt.Sprintf("concurrent task %d", idx)
|
||||
|
||||
_, _ = harness.Emit(extensions.SubagentStartEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
})
|
||||
|
||||
// Emit many chunks rapidly
|
||||
for j := range 20 {
|
||||
_, _ = harness.Emit(extensions.SubagentChunkEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
ChunkType: "text",
|
||||
Content: fmt.Sprintf("agent %d chunk %d", idx, j),
|
||||
})
|
||||
}
|
||||
|
||||
_, _ = harness.Emit(extensions.SubagentEndEvent{
|
||||
ToolCallID: callID,
|
||||
Task: task,
|
||||
Response: "done",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for range 5 {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Allow any final processing
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
|
||||
// even with nil ctx functions.
|
||||
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build ignore
|
||||
|
||||
// sudo-handler.go - Extension to handle sudo password prompts securely
|
||||
//
|
||||
// This extension intercepts bash commands containing "sudo" and:
|
||||
// 1. Checks if sudo credentials are already cached (via sudo -n)
|
||||
// 2. If not cached, prompts the user for their password (with masking)
|
||||
// 3. Temporarily sets SUDO_PASSWORD environment variable for execution
|
||||
// 4. The bash tool automatically uses sudo -S -p '' to pipe the password
|
||||
//
|
||||
// Usage: kit -e examples/extensions/sudo-handler.go
|
||||
//
|
||||
// Security notes:
|
||||
// - Password is only stored in memory for the duration of the session
|
||||
// - Password is never logged or displayed
|
||||
// - Each session requires re-authentication (sudo -k is used)
|
||||
// - The SUDO_PASSWORD env var is set only during tool execution
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
// cachedPassword stores the sudo password for the session
|
||||
cachedPassword string
|
||||
// hasCachedPassword tracks if we have a valid cached password
|
||||
hasCachedPassword bool
|
||||
// mu protects cached password access
|
||||
mu sync.RWMutex
|
||||
)
|
||||
|
||||
// Init sets up the sudo handler extension
|
||||
func Init(api ext.API) {
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName != "bash" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the command from tool input
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if command contains sudo
|
||||
if !containsSudo(input.Command) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we already have cached credentials
|
||||
mu.RLock()
|
||||
password := cachedPassword
|
||||
hasCached := hasCachedPassword
|
||||
mu.RUnlock()
|
||||
|
||||
if hasCached {
|
||||
// Use cached password
|
||||
os.Setenv("SUDO_PASSWORD", password)
|
||||
return nil
|
||||
}
|
||||
|
||||
// No cached password - prompt user
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "🔐 Sudo password required for:\n " + truncateCommand(input.Command, 60),
|
||||
Placeholder: "Enter your password",
|
||||
})
|
||||
|
||||
if result.Cancelled {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Sudo password prompt cancelled by user",
|
||||
}
|
||||
}
|
||||
|
||||
if result.Value == "" {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "No password provided",
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the password for this session
|
||||
mu.Lock()
|
||||
cachedPassword = result.Value
|
||||
hasCachedPassword = true
|
||||
mu.Unlock()
|
||||
|
||||
// Set environment variable for the bash tool to use
|
||||
os.Setenv("SUDO_PASSWORD", result.Value)
|
||||
|
||||
// Show confirmation (without revealing password)
|
||||
ctx.PrintInfo("Sudo password cached for this session")
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Clear cached password when session ends
|
||||
api.OnSessionShutdown(func(event ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
cachedPassword = ""
|
||||
hasCachedPassword = false
|
||||
mu.Unlock()
|
||||
os.Unsetenv("SUDO_PASSWORD")
|
||||
})
|
||||
}
|
||||
|
||||
// containsSudo checks if the command contains sudo as a command (not in a string)
|
||||
func containsSudo(command string) bool {
|
||||
// Simple check for sudo as a word, not inside quotes or as part of another word
|
||||
lower := strings.ToLower(command)
|
||||
|
||||
// Check for sudo at start or after separators
|
||||
patterns := []string{
|
||||
"sudo ",
|
||||
"sudo\t",
|
||||
";sudo ",
|
||||
"&& sudo ",
|
||||
"|| sudo ",
|
||||
"| sudo ",
|
||||
"$(sudo ",
|
||||
"`sudo ",
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if command starts with sudo
|
||||
if strings.HasPrefix(lower, "sudo ") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateCommand truncates a long command for display
|
||||
func truncateCommand(cmd string, maxLen int) string {
|
||||
if len(cmd) <= maxLen {
|
||||
return cmd
|
||||
}
|
||||
return cmd[:maxLen-3] + "..."
|
||||
}
|
||||
+29
-1
@@ -94,6 +94,12 @@ type ReasoningCompleteHandler func()
|
||||
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
|
||||
type ToolOutputHandler = core.ToolOutputCallback
|
||||
|
||||
// PasswordPromptHandler is a function type for password prompts.
|
||||
// Used by the bash tool when sudo requires a password. The handler receives
|
||||
// a prompt message and returns the password and whether it was cancelled.
|
||||
// Note: This is an alias for core.PasswordPromptCallback.
|
||||
type PasswordPromptHandler = core.PasswordPromptCallback
|
||||
|
||||
// StepMessagesHandler is a function type for persisting messages after each
|
||||
// complete step in a multi-step agent turn. The handler receives the messages
|
||||
// produced by the step (typically an assistant message with tool calls followed
|
||||
@@ -405,7 +411,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil)
|
||||
onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
|
||||
@@ -420,6 +426,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepMessages StepMessagesHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
onPasswordPrompt PasswordPromptHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Wait for background MCP tool loading to complete and rebuild the
|
||||
@@ -432,6 +439,11 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
|
||||
}
|
||||
|
||||
// Inject password prompt handler into context for use by bash tool.
|
||||
if onPasswordPrompt != nil {
|
||||
ctx = core.ContextWithPasswordPrompt(ctx, onPasswordPrompt)
|
||||
}
|
||||
|
||||
// The agent requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text and files as the prompt, and pass everything
|
||||
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
|
||||
@@ -1013,6 +1025,22 @@ func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
}
|
||||
|
||||
// GetMaxTokens returns the effective max output tokens the agent currently
|
||||
// sends to the LLM provider, after per-model defaults, right-sizing, and any
|
||||
// Anthropic thinking-budget adjustments. Returns 0 when no ModelConfig is
|
||||
// attached (e.g. early init) or when the provider suppresses the parameter
|
||||
// (e.g. Codex OAuth), which allows callers to differentiate "default" from
|
||||
// "explicitly capped".
|
||||
func (a *Agent) GetMaxTokens() int {
|
||||
if a.skipMaxOutputTokens {
|
||||
return 0
|
||||
}
|
||||
if a.modelConfig == nil {
|
||||
return 0
|
||||
}
|
||||
return a.modelConfig.MaxTokens
|
||||
}
|
||||
|
||||
// Close closes the agent and cleans up resources.
|
||||
// If MCP tools are still loading in the background, Close waits for them
|
||||
// to finish before closing connections to avoid resource leaks.
|
||||
|
||||
@@ -918,6 +918,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
sendFn(SteerConsumedEvent{})
|
||||
case kit.StepUsageEvent:
|
||||
a.recordStepUsage(ev, stepUsageSeen)
|
||||
case kit.PasswordPromptEvent:
|
||||
// Convert SDK PasswordPromptEvent to app PasswordPromptEvent
|
||||
// The TUI will handle this and send the response back
|
||||
responseCh := make(chan PasswordPromptResponse, 1)
|
||||
sendFn(PasswordPromptEvent{
|
||||
Prompt: ev.Prompt,
|
||||
ResponseCh: responseCh,
|
||||
})
|
||||
// Wait for TUI response and forward to SDK
|
||||
resp := <-responseCh
|
||||
ev.ResponseCh <- kit.PasswordPromptResponse{
|
||||
Password: resp.Password,
|
||||
Cancelled: resp.Cancelled,
|
||||
}
|
||||
case kit.TurnEndEvent:
|
||||
a.handleTurnEnd(ev, sendFn)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -928,6 +944,64 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo
|
||||
}
|
||||
}
|
||||
|
||||
// handleTurnEnd inspects a turn's final StopReason and surfaces actionable
|
||||
// feedback to the user when the turn ended in a state they can act on.
|
||||
//
|
||||
// Today the only surfaced case is FinishReasonLength — the model hit its
|
||||
// configured max_output_tokens budget and the reply was truncated. Without
|
||||
// this banner the TUI used to swallow the truncation silently, leading to
|
||||
// "ghost" cut-offs with no indication of why.
|
||||
//
|
||||
// Separated from subscribeSDKEvents so tests can exercise it directly via a
|
||||
// stubbed sendFn without standing up a full Kit.
|
||||
func (a *App) handleTurnEnd(ev kit.TurnEndEvent, sendFn func(tea.Msg)) {
|
||||
if sendFn == nil {
|
||||
return
|
||||
}
|
||||
if ev.StopReason != kit.FinishReasonLength {
|
||||
return
|
||||
}
|
||||
sendFn(ExtensionPrintEvent{
|
||||
Level: "info",
|
||||
Text: a.formatMaxTokensTruncatedMessage(),
|
||||
})
|
||||
}
|
||||
|
||||
// formatMaxTokensTruncatedMessage builds the user-facing explanation for a
|
||||
// truncated turn. It reports the active max_output_tokens budget and, when
|
||||
// known, the model's catalog output ceiling so the user can judge how much
|
||||
// headroom is available.
|
||||
func (a *App) formatMaxTokensTruncatedMessage() string {
|
||||
k := a.opts.Kit
|
||||
if k == nil {
|
||||
// Extremely early / test-stub case: still emit a useful generic hint.
|
||||
return "⚠ Response truncated: the model hit the configured max_output_tokens limit. " +
|
||||
"Raise it with --max-tokens N, KIT_MAX_TOKENS=N, or per-model " +
|
||||
"modelSettings[provider/model].maxTokens in config."
|
||||
}
|
||||
current := k.MaxTokens()
|
||||
ceiling := k.MaxOutputLimit()
|
||||
model := k.GetModelString()
|
||||
|
||||
msg := "⚠ Response truncated: "
|
||||
if model != "" {
|
||||
msg += fmt.Sprintf("%s hit the configured max_output_tokens limit", model)
|
||||
} else {
|
||||
msg += "the model hit the configured max_output_tokens limit"
|
||||
}
|
||||
if current > 0 {
|
||||
msg += fmt.Sprintf(" (%d)", current)
|
||||
}
|
||||
msg += "."
|
||||
if ceiling > 0 && current > 0 && ceiling > current {
|
||||
msg += fmt.Sprintf(" This model supports up to %d output tokens.", ceiling)
|
||||
}
|
||||
msg += "\n\nRaise it with --max-tokens N, KIT_MAX_TOKENS=N, " +
|
||||
"or per-model modelSettings[provider/model].maxTokens in your config. " +
|
||||
"Re-run the last prompt after raising it to get the full response."
|
||||
return msg
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
|
||||
@@ -3,10 +3,12 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
@@ -666,3 +668,94 @@ func TestUpdateUsageFromTurnResult_contextTokensUsesAllCategories(t *testing.T)
|
||||
expected, usage.contextCalls, usage.lastContextTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_LengthEmitsWarning verifies that when the SDK reports a
|
||||
// FinishReasonLength (max_output_tokens hit), the app surfaces a user-visible
|
||||
// ExtensionPrintEvent with Level="info" so the TUI can render a banner
|
||||
// instead of silently showing a truncated reply.
|
||||
func TestHandleTurnEnd_LengthEmitsWarning(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []tea.Msg
|
||||
sendFn := func(m tea.Msg) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, m)
|
||||
}
|
||||
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, sendFn)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(received) != 1 {
|
||||
t.Fatalf("expected 1 event on length stop, got %d", len(received))
|
||||
}
|
||||
ev, ok := received[0].(ExtensionPrintEvent)
|
||||
if !ok {
|
||||
t.Fatalf("expected ExtensionPrintEvent, got %T", received[0])
|
||||
}
|
||||
if ev.Level != "info" {
|
||||
t.Errorf("expected Level=info, got %q", ev.Level)
|
||||
}
|
||||
if ev.Text == "" {
|
||||
t.Error("expected non-empty warning text")
|
||||
}
|
||||
if !strings.Contains(ev.Text, "max_output_tokens") {
|
||||
t.Errorf("warning text should mention max_output_tokens, got: %s", ev.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_NonLengthIgnored verifies that ordinary stop reasons
|
||||
// (stop, tool-calls, error, unknown, "") do not produce a warning banner.
|
||||
func TestHandleTurnEnd_NonLengthIgnored(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
reasons := []string{
|
||||
kit.FinishReasonStop,
|
||||
kit.FinishReasonToolCalls,
|
||||
kit.FinishReasonError,
|
||||
kit.FinishReasonContentFilter,
|
||||
kit.FinishReasonOther,
|
||||
kit.FinishReasonUnknown,
|
||||
"",
|
||||
}
|
||||
for _, r := range reasons {
|
||||
var called bool
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: r}, func(m tea.Msg) {
|
||||
called = true
|
||||
})
|
||||
if called {
|
||||
t.Errorf("stop reason %q unexpectedly emitted a warning", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTurnEnd_NilSendFn guards against panics when no TUI listener is
|
||||
// attached (e.g. early init or headless teardown).
|
||||
func TestHandleTurnEnd_NilSendFn(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
// Should not panic with a nil sendFn.
|
||||
app.handleTurnEnd(kit.TurnEndEvent{StopReason: kit.FinishReasonLength}, nil)
|
||||
}
|
||||
|
||||
// TestFormatMaxTokensTruncatedMessage_NoKit verifies the fallback message
|
||||
// when Options.Kit is nil (test/stub path).
|
||||
func TestFormatMaxTokensTruncatedMessage_NoKit(t *testing.T) {
|
||||
app := New(Options{}, nil)
|
||||
defer app.Close()
|
||||
|
||||
msg := app.formatMaxTokensTruncatedMessage()
|
||||
if msg == "" {
|
||||
t.Fatal("expected non-empty fallback message")
|
||||
}
|
||||
for _, needle := range []string{"max_output_tokens", "--max-tokens", "KIT_MAX_TOKENS", "modelSettings"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("fallback message missing %q:\n%s", needle, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,24 @@ type ToolCallContentEvent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// PasswordPromptEvent is sent when a sudo command needs a password.
|
||||
// The TUI should display a password prompt overlay and send the result back.
|
||||
type PasswordPromptEvent struct {
|
||||
// Prompt is the message to display to the user.
|
||||
Prompt string
|
||||
// ResponseCh receives the password from the TUI.
|
||||
// The TUI must send exactly one value.
|
||||
ResponseCh chan<- PasswordPromptResponse
|
||||
}
|
||||
|
||||
// PasswordPromptResponse carries the user's password input.
|
||||
type PasswordPromptResponse struct {
|
||||
// Password is the entered password.
|
||||
Password string
|
||||
// Cancelled is true if the user cancelled the prompt.
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// ResponseCompleteEvent is sent when the LLM produces a final (non-streaming) response.
|
||||
// In streaming mode, this may be empty if all content was delivered via StreamChunkEvents.
|
||||
type ResponseCompleteEvent struct {
|
||||
|
||||
+176
-6
@@ -19,10 +19,18 @@ import (
|
||||
// It receives tool call ID, tool name, output chunk, and whether it's stderr.
|
||||
type ToolOutputCallback func(toolCallID, toolName, chunk string, isStderr bool)
|
||||
|
||||
// PasswordPromptCallback is the signature for password prompts.
|
||||
// It receives a prompt message and returns the password and whether it was cancelled.
|
||||
type PasswordPromptCallback func(prompt string) (password string, cancelled bool)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions.
|
||||
type contextKey string
|
||||
|
||||
const toolOutputCallbackKey contextKey = "toolOutputCallback"
|
||||
const (
|
||||
toolOutputCallbackKey contextKey = "toolOutputCallback"
|
||||
sudoPasswordKey contextKey = "sudoPassword"
|
||||
passwordPromptKey contextKey = "passwordPrompt"
|
||||
)
|
||||
|
||||
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
|
||||
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
|
||||
@@ -37,6 +45,34 @@ func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextWithPasswordPrompt returns a new context with the password prompt callback set.
|
||||
// This allows the TUI to show a modal password prompt when sudo needs a password.
|
||||
func ContextWithPasswordPrompt(ctx context.Context, callback PasswordPromptCallback) context.Context {
|
||||
return context.WithValue(ctx, passwordPromptKey, callback)
|
||||
}
|
||||
|
||||
// passwordPromptFromContext retrieves the password prompt callback from context.
|
||||
func passwordPromptFromContext(ctx context.Context) PasswordPromptCallback {
|
||||
if cb, ok := ctx.Value(passwordPromptKey).(PasswordPromptCallback); ok {
|
||||
return cb
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextWithSudoPassword returns a new context with the sudo password set.
|
||||
// When present, the bash tool will use sudo -S to pipe this password to sudo commands.
|
||||
func ContextWithSudoPassword(ctx context.Context, password string) context.Context {
|
||||
return context.WithValue(ctx, sudoPasswordKey, password)
|
||||
}
|
||||
|
||||
// sudoPasswordFromContext retrieves the sudo password from context.
|
||||
func sudoPasswordFromContext(ctx context.Context) string {
|
||||
if pw, ok := ctx.Value(sudoPasswordKey).(string); ok {
|
||||
return pw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const defaultBashTimeout = 120 * time.Second
|
||||
const maxBashTimeout = 600 * time.Second
|
||||
|
||||
@@ -73,6 +109,66 @@ func NewBashTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
// sudoCommandRe matches sudo commands that need to be rewritten for -S mode.
|
||||
// It matches "sudo" as a word boundary, optionally preceded by environment variables.
|
||||
var sudoCommandRe = regexp.MustCompile(`(?i)(^|[&|;|]|\|\||&&)\s*(\w+=\S+\s+)?\bsudo\b`)
|
||||
|
||||
// truncateCommand truncates a long command for display.
|
||||
func truncateCommand(cmd string, maxLen int) string {
|
||||
if len(cmd) <= maxLen {
|
||||
return cmd
|
||||
}
|
||||
return cmd[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// rewriteSudoForStdin rewrites sudo commands to use -S -p ” for stdin password input.
|
||||
// It transforms: sudo cmd → sudo -S -p ” cmd
|
||||
func rewriteSudoForStdin(command string) string {
|
||||
// Find all matches and their positions
|
||||
matches := sudoCommandRe.FindAllStringIndex(command, -1)
|
||||
if matches == nil {
|
||||
return command
|
||||
}
|
||||
|
||||
// Build result from end to start to preserve indices
|
||||
result := command
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
match := matches[i]
|
||||
start, end := match[0], match[1]
|
||||
matchedText := result[start:end]
|
||||
|
||||
// Extract just the "sudo" part (after any prefix)
|
||||
sudoIdx := strings.Index(strings.ToLower(matchedText), "sudo")
|
||||
if sudoIdx == -1 {
|
||||
continue
|
||||
}
|
||||
prefix := matchedText[:sudoIdx]
|
||||
sudoPart := matchedText[sudoIdx:]
|
||||
|
||||
// Check if the text immediately after "sudo" in the result contains -S
|
||||
afterSudo := result[end:]
|
||||
if strings.HasPrefix(strings.TrimLeft(afterSudo, " \t"), "-S") {
|
||||
// Already has -S flag, skip
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert -S -p '' after "sudo"
|
||||
newSudo := strings.Replace(sudoPart, "sudo", "sudo -S -p ''", 1)
|
||||
result = result[:start] + prefix + newSudo + result[end:]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SudoPasswordRequiredResult is a special marker that indicates sudo needs a password.
|
||||
// This is stored in tool response metadata to signal the TUI to prompt for password.
|
||||
const SudoPasswordRequiredMetadata = `{"sudo_password_required":true}`
|
||||
|
||||
// IsSudoPasswordRequiredResult checks if a tool response indicates sudo password is needed.
|
||||
func IsSudoPasswordRequiredResult(resp fantasy.ToolResponse) bool {
|
||||
return resp.Metadata == SudoPasswordRequiredMetadata
|
||||
}
|
||||
|
||||
func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
|
||||
var args bashArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
@@ -97,7 +193,47 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "bash", "-c", args.Command)
|
||||
// Check for sudo password in context or environment
|
||||
sudoPassword := sudoPasswordFromContext(ctx)
|
||||
if sudoPassword == "" {
|
||||
sudoPassword = os.Getenv("SUDO_PASSWORD")
|
||||
}
|
||||
command := args.Command
|
||||
|
||||
// If command contains sudo and we don't have a password, check if sudo needs one
|
||||
if sudoPassword == "" && sudoCommandRe.MatchString(command) {
|
||||
// Check if sudo credentials are cached using sudo -n (non-interactive)
|
||||
testCmd := exec.CommandContext(cmdCtx, "sudo", "-n", "true")
|
||||
testCmd.Dir = workDir
|
||||
if err := testCmd.Run(); err != nil {
|
||||
// Sudo needs a password - try to prompt via callback
|
||||
if promptCallback := passwordPromptFromContext(ctx); promptCallback != nil {
|
||||
pw, cancelled := promptCallback("Sudo password required for: " + truncateCommand(args.Command, 60))
|
||||
if cancelled {
|
||||
return fantasy.NewTextErrorResponse("sudo password prompt cancelled"), nil
|
||||
}
|
||||
if pw == "" {
|
||||
return fantasy.NewTextErrorResponse("no sudo password provided"), nil
|
||||
}
|
||||
sudoPassword = pw
|
||||
command = rewriteSudoForStdin(command)
|
||||
} else {
|
||||
// No callback available - return error with helpful message
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"This command requires sudo access. " +
|
||||
"Please run 'sudo -v' in your terminal first to cache credentials, " +
|
||||
"or set the SUDO_PASSWORD environment variable."), nil
|
||||
}
|
||||
}
|
||||
// Credentials are cached or password was provided, proceed
|
||||
}
|
||||
|
||||
// If we have a sudo password, rewrite the command to use sudo -S
|
||||
if sudoPassword != "" && sudoCommandRe.MatchString(command) {
|
||||
command = rewriteSudoForStdin(command)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "bash", "-c", command)
|
||||
if workDir != "" {
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
@@ -115,18 +251,18 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
|
||||
if outputCallback != nil {
|
||||
// Streaming mode: use pipes to capture output as it arrives
|
||||
return executeBashStreaming(cmdCtx, call, cmd, outputCallback)
|
||||
return executeBashStreaming(cmdCtx, call, cmd, outputCallback, sudoPassword)
|
||||
}
|
||||
|
||||
// Non-streaming mode: collect all output at once (original behavior)
|
||||
return executeBashBuffered(cmdCtx, call, cmd)
|
||||
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd) (fantasy.ToolResponse, error) {
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
@@ -136,10 +272,27 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe and write the password
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
|
||||
}()
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
@@ -181,7 +334,7 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
|
||||
}
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback) (fantasy.ToolResponse, error) {
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
@@ -191,11 +344,28 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// If we have a sudo password, create a stdin pipe
|
||||
var stdinPipe io.WriteCloser
|
||||
if sudoPassword != "" {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Write password to stdin if needed, then close stdin
|
||||
if sudoPassword != "" && stdinPipe != nil {
|
||||
go func() {
|
||||
defer func() { _ = stdinPipe.Close() }()
|
||||
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
|
||||
}()
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
@@ -127,3 +127,72 @@ func TestBash_EmptyCommand(t *testing.T) {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSudoForStdin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple sudo",
|
||||
input: "sudo apt update",
|
||||
expected: "sudo -S -p '' apt update",
|
||||
},
|
||||
{
|
||||
name: "sudo with env var",
|
||||
input: "DEBIAN_FRONTEND=noninteractive sudo apt update",
|
||||
expected: "DEBIAN_FRONTEND=noninteractive sudo -S -p '' apt update",
|
||||
},
|
||||
{
|
||||
name: "sudo in pipeline",
|
||||
input: "echo test | sudo tee /etc/test.conf",
|
||||
expected: "echo test | sudo -S -p '' tee /etc/test.conf",
|
||||
},
|
||||
{
|
||||
name: "sudo after &&",
|
||||
input: "apt update && sudo apt upgrade",
|
||||
expected: "apt update && sudo -S -p '' apt upgrade",
|
||||
},
|
||||
{
|
||||
name: "already has -S flag",
|
||||
input: "sudo -S apt update",
|
||||
expected: "sudo -S apt update",
|
||||
},
|
||||
{
|
||||
name: "no sudo",
|
||||
input: "apt update && apt upgrade",
|
||||
expected: "apt update && apt upgrade",
|
||||
},
|
||||
{
|
||||
name: "sudo in string (should not match)",
|
||||
input: "echo 'use sudo carefully'",
|
||||
expected: "echo 'use sudo carefully'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := rewriteSudoForStdin(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("rewriteSudoForStdin(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSudoPasswordFromContext(t *testing.T) {
|
||||
// Test with password in context
|
||||
ctx := ContextWithSudoPassword(context.Background(), "secret123")
|
||||
pw := sudoPasswordFromContext(ctx)
|
||||
if pw != "secret123" {
|
||||
t.Errorf("expected password 'secret123', got %q", pw)
|
||||
}
|
||||
|
||||
// Test without password
|
||||
ctx = context.Background()
|
||||
pw = sudoPasswordFromContext(ctx)
|
||||
if pw != "" {
|
||||
t.Errorf("expected empty password, got %q", pw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ Example use cases:
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
"description": "Optional model override. Empty string uses the current model.",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
@@ -94,7 +94,7 @@ Example use cases:
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800, minimum recommended: 240)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
|
||||
@@ -1,21 +1,93 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reentrantMu — a per-extension mutex that allows the same goroutine to
|
||||
// re-enter (e.g. handler → ctx.EmitCustomEvent → handler in same extension).
|
||||
// Different goroutines are serialized, preventing concurrent state mutation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type reentrantMu struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
owner int64 // goroutine ID that holds the lock, or 0
|
||||
depth int // re-entrancy depth
|
||||
}
|
||||
|
||||
// initReentrantMu initializes the reentrant mutex in-place. Must be called
|
||||
// after the struct is at its final memory location (not before copying).
|
||||
func (r *reentrantMu) init() {
|
||||
r.cond = sync.NewCond(&r.mu)
|
||||
}
|
||||
|
||||
// lock acquires the mutex. If the calling goroutine already holds it, the
|
||||
// call succeeds immediately (re-entrant). Every call to lock must be paired
|
||||
// with a call to unlock.
|
||||
func (r *reentrantMu) lock() {
|
||||
gid := goroutineID()
|
||||
r.mu.Lock()
|
||||
if r.owner == gid {
|
||||
// Re-entrant: same goroutine already holds the lock.
|
||||
r.depth++
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Wait for the current owner to release.
|
||||
for r.owner != 0 {
|
||||
r.cond.Wait() // releases mu, blocks, re-acquires mu on wake
|
||||
}
|
||||
r.owner = gid
|
||||
r.depth = 1
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// unlock releases the mutex (or decrements re-entrancy depth).
|
||||
func (r *reentrantMu) unlock() {
|
||||
r.mu.Lock()
|
||||
r.depth--
|
||||
if r.depth == 0 {
|
||||
r.owner = 0
|
||||
r.cond.Signal()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// goroutineID extracts the current goroutine's ID from runtime.Stack output.
|
||||
// This is a well-known technique used by Go testing infrastructure.
|
||||
func goroutineID() int64 {
|
||||
var buf [64]byte
|
||||
n := runtime.Stack(buf[:], false)
|
||||
// Stack output starts with "goroutine NNN ["
|
||||
s := buf[:n]
|
||||
s = s[len("goroutine "):]
|
||||
s = s[:bytes.IndexByte(s, ' ')]
|
||||
id, _ := strconv.ParseInt(string(s), 10, 64)
|
||||
return id
|
||||
}
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
//
|
||||
// Each extension has a dedicated reentrant mutex so that handlers for the
|
||||
// same extension are serialized (preventing data races on shared package-level
|
||||
// state), while handlers for different extensions may execute concurrently.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
extMu []reentrantMu // per-extension reentrant mutex, indexed by extension position
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
@@ -52,7 +124,11 @@ type LoadedExtension struct {
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
func NewRunner(exts []LoadedExtension) *Runner {
|
||||
return &Runner{extensions: exts}
|
||||
mus := make([]reentrantMu, len(exts))
|
||||
for i := range mus {
|
||||
mus[i].init()
|
||||
}
|
||||
return &Runner{extensions: exts, extMu: mus}
|
||||
}
|
||||
|
||||
// SetContext updates the runtime context (session ID, model, etc.) that is
|
||||
@@ -367,6 +443,11 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
for i := range r.extensions {
|
||||
ext := &r.extensions[i]
|
||||
handlers := ext.Handlers[event.Type()]
|
||||
if len(handlers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
r.extMu[i].lock()
|
||||
for _, handler := range handlers {
|
||||
result, err := safeCall(handler, event, ctx)
|
||||
if err != nil {
|
||||
@@ -379,6 +460,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
|
||||
// Check for blocking/short-circuit results.
|
||||
if isBlocking(result) {
|
||||
r.extMu[i].unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -386,6 +468,7 @@ func (r *Runner) Emit(event Event) (Result, error) {
|
||||
// the caller is responsible for applying the modifications.
|
||||
accumulated = result
|
||||
}
|
||||
r.extMu[i].unlock()
|
||||
}
|
||||
return accumulated, nil
|
||||
}
|
||||
@@ -712,11 +795,17 @@ func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
extHandlers := r.extensions[i].CustomEventHandlers[name]
|
||||
if len(extHandlers) == 0 {
|
||||
continue
|
||||
}
|
||||
r.extMu[i].lock()
|
||||
for _, h := range extHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
r.extMu[i].unlock()
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
// Then dynamic subscriptions (not extension-scoped, no per-ext lock).
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -571,3 +572,142 @@ func TestRunner_ContextPrintNilSafe(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ConcurrentEmitSameExtension(t *testing.T) {
|
||||
// Verify that concurrent Emit calls for the same extension are serialized
|
||||
// and don't cause data races on shared handler state.
|
||||
var counter int
|
||||
ext := makeHandlerExt("shared-state.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
// Read-modify-write: racy without serialization.
|
||||
v := counter
|
||||
counter = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
SubagentChunk: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter
|
||||
counter = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
r := makeRunner(ext)
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
|
||||
_, _ = r.Emit(SubagentChunkEvent{ToolCallID: "x"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if counter != goroutines*iterations*2 {
|
||||
t.Errorf("expected counter=%d, got %d (race detected)", goroutines*iterations*2, counter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ConcurrentEmitDifferentExtensions(t *testing.T) {
|
||||
// Two extensions with independent state should not block each other
|
||||
// and should both run correctly under concurrent Emit calls.
|
||||
var counter1, counter2 int
|
||||
ext1 := makeHandlerExt("ext1.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter1
|
||||
counter1 = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
ext2 := makeHandlerExt("ext2.go", map[EventType][]HandlerFunc{
|
||||
SubagentStart: {
|
||||
func(e Event, c Context) Result {
|
||||
v := counter2
|
||||
counter2 = v + 1
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
r := makeRunner(ext1, ext2)
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
wg.Add(goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range iterations {
|
||||
_, _ = r.Emit(SubagentStartEvent{ToolCallID: "x"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
expected := goroutines * iterations
|
||||
if counter1 != expected {
|
||||
t.Errorf("ext1 counter: expected %d, got %d", expected, counter1)
|
||||
}
|
||||
if counter2 != expected {
|
||||
t.Errorf("ext2 counter: expected %d, got %d", expected, counter2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ReentrantEmitCustomEvent(t *testing.T) {
|
||||
// Verify that a handler can call EmitCustomEvent (which dispatches to
|
||||
// the same extension's custom event handlers) without deadlocking.
|
||||
var order []string
|
||||
ext := LoadedExtension{
|
||||
Path: "reentrant.go",
|
||||
Handlers: map[EventType][]HandlerFunc{
|
||||
SessionStart: {
|
||||
func(e Event, c Context) Result {
|
||||
order = append(order, "session_start")
|
||||
// This triggers EmitCustomEvent for the same extension
|
||||
// via a direct runner call (simulating ctx.EmitCustomEvent).
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomEventHandlers: map[string][]func(string){
|
||||
"test-event": {
|
||||
func(data string) {
|
||||
order = append(order, "custom:"+data)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
r := makeRunner(ext)
|
||||
|
||||
// Wire up the handler to call EmitCustomEvent re-entrantly.
|
||||
ext.Handlers[SessionStart] = []HandlerFunc{
|
||||
func(e Event, c Context) Result {
|
||||
order = append(order, "session_start")
|
||||
r.EmitCustomEvent("test-event", "hello")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
r.extensions[0] = ext
|
||||
// Rebuild mutexes after modifying extensions slice.
|
||||
r.extMu = make([]reentrantMu, len(r.extensions))
|
||||
for i := range r.extMu {
|
||||
r.extMu[i].init()
|
||||
}
|
||||
|
||||
_, err := r.Emit(SessionStartEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(order) != 2 || order[0] != "session_start" || order[1] != "custom:hello" {
|
||||
t.Errorf("expected [session_start, custom:hello], got %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -251,6 +251,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
// via CLI flag or global config.
|
||||
ApplyModelSettings(config, modelInfo)
|
||||
|
||||
// Auto-raise MaxTokens toward the model's known output ceiling when the
|
||||
// user hasn't explicitly set --max-tokens and no per-model override
|
||||
// applied. Runs after ApplyModelSettings so explicit modelSettings win.
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
// Create the base provider
|
||||
var result *ProviderResult
|
||||
var createErr error
|
||||
@@ -489,6 +494,37 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultRightSizeCap bounds auto-raised MaxTokens so that we don't silently
|
||||
// allocate enormous output budgets for models with very high ceilings (e.g.
|
||||
// Devstral at 262144, Mistral at 128000). Users who genuinely want more can
|
||||
// pass --max-tokens explicitly or set modelSettings[...].maxTokens in config.
|
||||
const defaultRightSizeCap = 32768
|
||||
|
||||
// rightSizeMaxTokens raises config.MaxTokens toward the model's known output
|
||||
// ceiling when:
|
||||
// - the user has not explicitly set --max-tokens (or the KIT_MAX_TOKENS env
|
||||
// var, or the top-level max-tokens key in config.yaml), AND
|
||||
// - no per-model override already bumped MaxTokens (ApplyModelSettings runs
|
||||
// before this function), AND
|
||||
// - modelInfo.Limit.Output is known and larger than the current MaxTokens.
|
||||
//
|
||||
// The raised value is capped at defaultRightSizeCap to keep accidental
|
||||
// allocations reasonable on very-large-output models. This prevents the
|
||||
// common "ghost" where the agent's reply is silently truncated at the 8192
|
||||
// default even though the selected model supports 64k or 262k output tokens.
|
||||
func rightSizeMaxTokens(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
if modelInfo == nil || modelInfo.Limit.Output <= 0 {
|
||||
return
|
||||
}
|
||||
if isExplicitlySet("max-tokens") {
|
||||
return
|
||||
}
|
||||
target := min(modelInfo.Limit.Output, defaultRightSizeCap)
|
||||
if config.MaxTokens < target {
|
||||
config.MaxTokens = target
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// bindMaxTokensFlag wires a fresh pflag-backed "max-tokens" key into viper so
|
||||
// isExplicitlySet behaves the same way it does in production. Returns a
|
||||
// cleanup function that removes the binding so sibling tests see a clean
|
||||
// state.
|
||||
func bindMaxTokensFlag(t *testing.T, args []string) func() {
|
||||
t.Helper()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
fs.Int("max-tokens", 8192, "")
|
||||
if err := viper.BindPFlag("max-tokens", fs.Lookup("max-tokens")); err != nil {
|
||||
t.Fatalf("BindPFlag: %v", err)
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
t.Fatalf("fs.Parse: %v", err)
|
||||
}
|
||||
return func() {
|
||||
viper.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_RaisesWhenBelowCeiling(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil) // no args → flag.Changed = false
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "claude-sonnet-4-5",
|
||||
Limit: Limit{Context: 200000, Output: 64000},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 32768 {
|
||||
t.Errorf("expected MaxTokens raised to defaultRightSizeCap (32768), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_CapsAtDefaultRightSizeCap(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Mistral Devstral has 262144 output — we should still cap at 32768.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "devstral-medium-latest",
|
||||
Limit: Limit{Context: 262144, Output: 262144},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != defaultRightSizeCap {
|
||||
t.Errorf("expected MaxTokens capped at %d, got %d", defaultRightSizeCap, config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_UsesExactOutputWhenBelowCap(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 4096}
|
||||
// Model with output limit smaller than the cap.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "gpt-4",
|
||||
Limit: Limit{Context: 8192, Output: 8192},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens raised to model output ceiling (8192), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_DoesNotLowerCurrentValue(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
// User (via per-model settings, applied earlier) already bumped MaxTokens
|
||||
// above the cap — we must not clobber their choice.
|
||||
config := &ProviderConfig{MaxTokens: 100000}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "devstral-medium-latest",
|
||||
Limit: Limit{Context: 262144, Output: 262144},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 100000 {
|
||||
t.Errorf("expected MaxTokens preserved at 100000, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_RespectsExplicitFlag(t *testing.T) {
|
||||
// Simulate `--max-tokens 4096` on the command line.
|
||||
cleanup := bindMaxTokensFlag(t, []string{"--max-tokens", "4096"})
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 4096}
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "claude-sonnet-4-5",
|
||||
Limit: Limit{Context: 200000, Output: 64000},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 4096 {
|
||||
t.Errorf("expected explicit --max-tokens to be preserved (4096), got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_NilModelInfo(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Custom model / Ollama / unknown provider → no model info.
|
||||
rightSizeMaxTokens(config, nil)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens unchanged with nil modelInfo, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRightSizeMaxTokens_ZeroOutputLimit(t *testing.T) {
|
||||
cleanup := bindMaxTokensFlag(t, nil)
|
||||
defer cleanup()
|
||||
|
||||
config := &ProviderConfig{MaxTokens: 8192}
|
||||
// Model present in catalog but with no known output limit.
|
||||
modelInfo := &ModelInfo{
|
||||
ID: "unknown-model",
|
||||
Limit: Limit{Context: 0, Output: 0},
|
||||
}
|
||||
|
||||
rightSizeMaxTokens(config, modelInfo)
|
||||
|
||||
if config.MaxTokens != 8192 {
|
||||
t.Errorf("expected MaxTokens unchanged with zero output limit, got %d", config.MaxTokens)
|
||||
}
|
||||
}
|
||||
@@ -69,30 +69,6 @@ func TestInputComponent_SubmitEmitsSubmitMsg(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputComponent_CtrlD_SubmitEmitsSubmitMsg verifies that ctrl+d also
|
||||
// submits the text.
|
||||
func TestInputComponent_CtrlD_SubmitEmitsSubmitMsg(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
c := newTestInput(ctrl)
|
||||
|
||||
c.textarea.SetValue("ctrl+d submit")
|
||||
c.lastValue = "ctrl+d submit"
|
||||
|
||||
_, cmd := sendInputMsg(c, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
|
||||
|
||||
msg := runCmd(cmd)
|
||||
if msg == nil {
|
||||
t.Fatal("expected a cmd from ctrl+d on non-empty input")
|
||||
}
|
||||
sm, ok := msg.(core.SubmitMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected submitMsg from ctrl+d, got %T", msg)
|
||||
}
|
||||
if sm.Text != "ctrl+d submit" {
|
||||
t.Fatalf("expected Text='ctrl+d submit', got %q", sm.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputComponent_EmptySubmit_NoCmd verifies that submitting an empty or
|
||||
// whitespace-only string produces no cmd.
|
||||
func TestInputComponent_EmptySubmit_NoCmd(t *testing.T) {
|
||||
|
||||
@@ -145,7 +145,13 @@ func TestDetectMediaType(t *testing.T) {
|
||||
content []byte
|
||||
expected string
|
||||
}{
|
||||
{".go", nil, "text/plain"}, // .go falls back to content sniffing → text/plain
|
||||
// An intentionally-synthetic extension that is not registered
|
||||
// in any system MIME database. Exercises the "unknown ext +
|
||||
// no content" branch, which must return the text/plain default.
|
||||
// Do not use real extensions (e.g. .go) here: CI images often
|
||||
// ship /etc/mime.types with entries like ".go → text/x-go",
|
||||
// which would make the assertion environment-dependent.
|
||||
{".kitsyntheticext", nil, "text/plain"},
|
||||
{".png", []byte{0x89, 0x50, 0x4E, 0x47}, "image/png"},
|
||||
{".jpg", []byte{0xFF, 0xD8, 0xFF}, "image/jpeg"},
|
||||
{".pdf", []byte{0x25, 0x50, 0x44, 0x46}, "application/pdf"},
|
||||
|
||||
+20
-4
@@ -201,7 +201,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPressMsg:
|
||||
if !s.showPopup {
|
||||
switch msg.String() {
|
||||
case "ctrl+d", "enter":
|
||||
case "enter":
|
||||
value := s.textarea.Value()
|
||||
s.pushHistory(value)
|
||||
s.textarea.SetValue("")
|
||||
@@ -708,9 +708,25 @@ func (s *InputComponent) renderPopupWithOptions(centered bool) string {
|
||||
}
|
||||
content = indicator + displayName
|
||||
} else {
|
||||
nameWidth := 15
|
||||
if innerWidth < 25 {
|
||||
nameWidth = max(innerWidth*2/5+1, 8)
|
||||
// Compute nameWidth from the longest command name in the
|
||||
// visible slice so we never truncate unnecessarily.
|
||||
nameWidth := 0
|
||||
for _, fm := range s.filtered {
|
||||
if n := len([]rune(fm.Command.Name)); n > nameWidth {
|
||||
nameWidth = n
|
||||
}
|
||||
}
|
||||
nameWidth += 3 // account for indicator prefix (2) + gap before description (1)
|
||||
// Ensure descriptions still get at least 20 chars when possible.
|
||||
maxForName := innerWidth - 20
|
||||
if maxForName < 8 {
|
||||
maxForName = innerWidth * 2 / 3
|
||||
}
|
||||
if nameWidth > maxForName {
|
||||
nameWidth = maxForName
|
||||
}
|
||||
if nameWidth < 8 {
|
||||
nameWidth = 8
|
||||
}
|
||||
maxNameChars := nameWidth - 2
|
||||
displayName := sc.Name
|
||||
|
||||
+45
-9
@@ -1318,11 +1318,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.scrollList.autoScroll = true
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case "alt+home":
|
||||
case "ctrl+home":
|
||||
m.scrollList.GotoTop()
|
||||
m.scrollList.autoScroll = false
|
||||
return m, tea.Batch(cmds...)
|
||||
case "alt+end":
|
||||
case "ctrl+end":
|
||||
m.scrollList.GotoBottom()
|
||||
m.scrollList.autoScroll = true
|
||||
return m, tea.Batch(cmds...)
|
||||
@@ -1330,15 +1330,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// Thinking keybindings — only when the model supports reasoning.
|
||||
// Note: thinking visibility toggle is under leader chord (Ctrl+X t)
|
||||
// to avoid conflicts with terminal multiplexers.
|
||||
if m.isReasoningModel {
|
||||
switch msg.String() {
|
||||
case "ctrl+t":
|
||||
// Toggle thinking block visibility.
|
||||
m.thinkingVisible = !m.thinkingVisible
|
||||
if m.stream != nil {
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case "shift+tab":
|
||||
// Cycle thinking level.
|
||||
m.cycleThinkingLevel()
|
||||
@@ -1439,6 +1434,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
case "t":
|
||||
// Ctrl+X t → Toggle thinking block visibility.
|
||||
if m.isReasoningModel {
|
||||
m.thinkingVisible = !m.thinkingVisible
|
||||
if m.stream != nil {
|
||||
m.stream.SetThinkingVisible(m.thinkingVisible)
|
||||
}
|
||||
}
|
||||
case "e":
|
||||
// Ctrl+X e → open $EDITOR to compose/edit the prompt.
|
||||
editorApp := os.Getenv("VISUAL")
|
||||
@@ -2082,6 +2085,39 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
ic.textarea.CursorEnd()
|
||||
}
|
||||
|
||||
case app.PasswordPromptEvent:
|
||||
// Sudo password prompt - show a modal input prompt
|
||||
// If already in prompt state, cancel the new request
|
||||
if m.state == statePrompt {
|
||||
if msg.ResponseCh != nil {
|
||||
msg.ResponseCh <- app.PasswordPromptResponse{Cancelled: true}
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
m.prePromptState = m.state
|
||||
m.state = statePrompt
|
||||
// Create a custom response channel that converts PasswordPromptResponse
|
||||
passwordResponseCh := make(chan app.PromptResponse, 1)
|
||||
m.promptResponseCh = passwordResponseCh
|
||||
|
||||
// Create password input prompt (masked input)
|
||||
m.prompt = newPasswordPrompt(msg.Prompt, m.width, m.height)
|
||||
|
||||
// Handle the response conversion
|
||||
go func() {
|
||||
resp := <-passwordResponseCh
|
||||
if msg.ResponseCh != nil {
|
||||
msg.ResponseCh <- app.PasswordPromptResponse{
|
||||
Password: resp.Value,
|
||||
Cancelled: resp.Cancelled,
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if m.prompt != nil {
|
||||
cmds = append(cmds, m.prompt.Init())
|
||||
}
|
||||
|
||||
case app.PromptRequestEvent:
|
||||
// Extension wants to show an interactive prompt. Enter prompt state.
|
||||
// If already in prompt state (concurrent prompt from another
|
||||
|
||||
+78
-9
@@ -19,9 +19,10 @@ import (
|
||||
type promptMode string
|
||||
|
||||
const (
|
||||
promptModeSelect promptMode = "select"
|
||||
promptModeConfirm promptMode = "confirm"
|
||||
promptModeInput promptMode = "input"
|
||||
promptModeSelect promptMode = "select"
|
||||
promptModeConfirm promptMode = "confirm"
|
||||
promptModeInput promptMode = "input"
|
||||
promptModePassword promptMode = "password"
|
||||
)
|
||||
|
||||
// promptResult carries the synchronous outcome of a prompt overlay update.
|
||||
@@ -102,10 +103,38 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the prompt overlay. For input mode
|
||||
// this starts the cursor blink animation.
|
||||
// newPasswordPrompt creates a prompt overlay for password input (masked).
|
||||
func newPasswordPrompt(message string, width, height int) *promptOverlay {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Enter password"
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.CharLimit = 0
|
||||
ta.SetWidth(width - 12) // account for border + padding
|
||||
ta.SetHeight(1)
|
||||
ta.Focus()
|
||||
|
||||
// Prevent Enter from inserting a newline — we intercept it for submit.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
)
|
||||
|
||||
// Enable password masking - the textarea will show dots instead of characters
|
||||
// Note: textarea doesn't have built-in password masking, so we handle it in View()
|
||||
|
||||
return &promptOverlay{
|
||||
mode: promptModePassword,
|
||||
message: message,
|
||||
inputTA: ta,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
// Init returns the initial command for the prompt overlay. For input/password
|
||||
// modes this starts the cursor blink animation.
|
||||
func (p *promptOverlay) Init() tea.Cmd {
|
||||
if p.mode == promptModeInput {
|
||||
if p.mode == promptModeInput || p.mode == promptModePassword {
|
||||
return textarea.Blink
|
||||
}
|
||||
return nil
|
||||
@@ -113,13 +142,13 @@ func (p *promptOverlay) Init() tea.Cmd {
|
||||
|
||||
// Update handles messages for the prompt overlay. It returns a non-nil
|
||||
// *promptResult when the user completes or cancels the prompt. The returned
|
||||
// tea.Cmd is for textarea blink ticks (input mode only).
|
||||
// tea.Cmd is for textarea blink ticks (input/password modes only).
|
||||
func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
p.width = msg.Width
|
||||
p.height = msg.Height
|
||||
if p.mode == promptModeInput {
|
||||
if p.mode == promptModeInput || p.mode == promptModePassword {
|
||||
p.inputTA.SetWidth(p.width - 12)
|
||||
}
|
||||
return nil, nil
|
||||
@@ -132,11 +161,13 @@ func (p *promptOverlay) Update(msg tea.Msg) (*promptResult, tea.Cmd) {
|
||||
return p.updateConfirm(msg)
|
||||
case promptModeInput:
|
||||
return p.updateInput(msg)
|
||||
case promptModePassword:
|
||||
return p.updatePassword(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass non-key messages to textarea for blink animation.
|
||||
if p.mode == promptModeInput {
|
||||
if p.mode == promptModeInput || p.mode == promptModePassword {
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
@@ -202,6 +233,20 @@ func (p *promptOverlay) updateInput(msg tea.KeyPressMsg) (*promptResult, tea.Cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (p *promptOverlay) updatePassword(msg tea.KeyPressMsg) (*promptResult, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
return &promptResult{completed: true, value: p.inputTA.Value()}, nil
|
||||
case "esc":
|
||||
return &promptResult{cancelled: true}, nil
|
||||
default:
|
||||
// Delegate character input, backspace, cursor movement, etc.
|
||||
var cmd tea.Cmd
|
||||
p.inputTA, cmd = p.inputTA.Update(msg)
|
||||
return nil, cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Render returns the prompt as a styled string for inline composition in the
|
||||
// AppModel layout. The prompt replaces the normal input area (below the
|
||||
// separator and above the status bar) rather than taking over the full screen.
|
||||
@@ -216,6 +261,8 @@ func (p *promptOverlay) Render() string {
|
||||
content = p.viewConfirm(theme)
|
||||
case promptModeInput:
|
||||
content = p.viewInput(theme)
|
||||
case promptModePassword:
|
||||
content = p.viewPassword(theme)
|
||||
}
|
||||
|
||||
return renderContentBlock(content, p.width,
|
||||
@@ -286,3 +333,25 @@ func (p *promptOverlay) viewInput(theme style.Theme) string {
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (p *promptOverlay) viewPassword(theme style.Theme) string {
|
||||
var lines []string
|
||||
// Add 🔐 icon to message for password prompt
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(theme.Text).Render("🔐 "+p.message))
|
||||
lines = append(lines, "")
|
||||
|
||||
// Mask the password input with dots
|
||||
passwordValue := p.inputTA.Value()
|
||||
masked := strings.Repeat("•", len([]rune(passwordValue)))
|
||||
// Render the masked password in a style that looks like input
|
||||
maskedStyle := lipgloss.NewStyle().Foreground(theme.Text)
|
||||
cursor := lipgloss.NewStyle().Foreground(theme.Accent).Render("█")
|
||||
lines = append(lines, maskedStyle.Render(masked)+cursor)
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Render(" Enter submit Esc cancel (input is hidden)"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -588,8 +588,10 @@ func formatToolExecutionMessage(toolName string) string {
|
||||
return toolName
|
||||
}
|
||||
|
||||
// UpdateTheme refreshes the component's typography instance with colors from
|
||||
// the current theme. This is called when the user changes themes via /theme.
|
||||
// UpdateTheme refreshes the component's typography instance and spinner
|
||||
// animation frames with colors from the current theme. This is called when
|
||||
// the user changes themes via /theme.
|
||||
func (s *StreamComponent) UpdateTheme() {
|
||||
s.ty = createTypography(GetTheme())
|
||||
s.spinnerFrames = knightRiderFrames()
|
||||
}
|
||||
|
||||
@@ -200,10 +200,6 @@ func (ts *TreeSelectorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+l"))):
|
||||
ts.filter = TreeFilterLabelOnly
|
||||
ts.rebuildFlatList()
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+a"))):
|
||||
ts.filter = TreeFilterAll
|
||||
ts.rebuildFlatList()
|
||||
|
||||
default:
|
||||
// Typing search.
|
||||
if msg.Text != "" && len(msg.Text) == 1 {
|
||||
|
||||
+7
-4
@@ -224,10 +224,13 @@ kit.LLMResponse // {Content, FinishReason, Usage}
|
||||
kit.LLMFilePart // {Filename, Data []byte, MediaType}
|
||||
|
||||
// MCP OAuth types
|
||||
kit.MCPServer // *server.MCPServer for in-process MCP transport
|
||||
kit.MCPServerConfig // Configuration for an MCP server (stdio, SSE, or in-process)
|
||||
kit.MCPTokenStore // Persists OAuth tokens for a single MCP server
|
||||
kit.MCPToken // OAuth token (access token, refresh token, expiry)
|
||||
kit.MCPServer // *server.MCPServer for in-process MCP transport
|
||||
kit.MCPServerConfig // Configuration for an MCP server (stdio, SSE, or in-process)
|
||||
kit.MCPAuthHandler // Interface: handles user-facing OAuth authorization
|
||||
kit.DefaultMCPAuthHandler // Port + callback-server mechanics; set OnAuthURL for presentation
|
||||
kit.CLIMCPAuthHandler // CLI wrapper: opens browser, prints status
|
||||
kit.MCPTokenStore // Persists OAuth tokens for a single MCP server
|
||||
kit.MCPToken // OAuth token (access token, refresh token, expiry)
|
||||
kit.MCPTokenStoreFactory // Creates an MCPTokenStore for a given server URL
|
||||
|
||||
// Conversion helpers
|
||||
|
||||
+31
-10
@@ -38,20 +38,37 @@ Guidelines:
|
||||
- Be concise in your responses
|
||||
- Show file paths clearly when working with files`
|
||||
|
||||
// setSDKDefaults registers the same viper defaults that the CLI sets via
|
||||
// cobra flag bindings. This ensures the SDK behaves identically to the CLI
|
||||
// even when cobra is not used.
|
||||
// sdkDefaultMaxTokens is the last-resort ceiling applied when the SDK caller
|
||||
// has not configured max-tokens via Options, env, config, or a per-model
|
||||
// default. It matches the CLI's --max-tokens cobra default so SDK and CLI
|
||||
// callers see the same base value before per-model right-sizing runs.
|
||||
// It is intentionally applied on the *models.ProviderConfig struct
|
||||
// (not via viper) so that viper.IsSet("max-tokens") remains false and the
|
||||
// right-sizing + per-model-default paths continue to work.
|
||||
const sdkDefaultMaxTokens = 8192
|
||||
|
||||
// setSDKDefaults registers viper defaults that match the CLI's cobra flag
|
||||
// defaults for keys where SetDefault does not interfere with downstream
|
||||
// viper.IsSet() checks.
|
||||
//
|
||||
// Keys that participate in "explicit vs unset" precedence downstream —
|
||||
// max-tokens, temperature, top-p, top-k, frequency-penalty, presence-penalty,
|
||||
// thinking-level — are deliberately NOT registered here. viper.SetDefault
|
||||
// causes viper.IsSet() to return true, which would suppress per-model
|
||||
// defaults (ApplyModelSettings) and automatic right-sizing (rightSizeMaxTokens)
|
||||
// for every SDK-created Kit. Those defaults are instead applied:
|
||||
//
|
||||
// - max-tokens: as a last-resort struct-level floor (sdkDefaultMaxTokens)
|
||||
// in kit.New() after BuildProviderConfig returns, when the resolved
|
||||
// value is still zero.
|
||||
// - thinking-level: handled implicitly by models.ParseThinkingLevel("")
|
||||
// which returns models.ThinkingOff.
|
||||
// - sampling params (temperature, top-p, top-k, frequency/presence-penalty):
|
||||
// left as nil pointers so provider libraries apply their own defaults.
|
||||
func setSDKDefaults() {
|
||||
viper.SetDefault("model", "anthropic/claude-sonnet-4-5-20250929")
|
||||
viper.SetDefault("system-prompt", defaultSystemPrompt)
|
||||
viper.SetDefault("max-tokens", 4096)
|
||||
viper.SetDefault("temperature", 0.7)
|
||||
viper.SetDefault("top-p", 0.95)
|
||||
viper.SetDefault("top-k", 40)
|
||||
viper.SetDefault("frequency-penalty", 0.0)
|
||||
viper.SetDefault("presence-penalty", 0.0)
|
||||
viper.SetDefault("stream", true)
|
||||
viper.SetDefault("thinking-level", "off")
|
||||
viper.SetDefault("num-gpu-layers", -1)
|
||||
viper.SetDefault("main-gpu", 0)
|
||||
}
|
||||
@@ -102,6 +119,10 @@ func InitConfig(configFile string, debug bool) error {
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("KIT")
|
||||
// Map hyphenated config keys (e.g. "max-tokens") to underscored env
|
||||
// var names (e.g. KIT_MAX_TOKENS). Without this, AutomaticEnv looks
|
||||
// for KIT_MAX-TOKENS and silently misses valid env overrides.
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
return nil
|
||||
}
|
||||
|
||||
+61
-3
@@ -45,6 +45,8 @@ const (
|
||||
// EventToolOutput fires when a tool produces streaming output chunks.
|
||||
EventToolOutput EventType = "tool_output"
|
||||
EventStepUsage EventType = "step_usage"
|
||||
// EventPasswordPrompt fires when a sudo command needs a password.
|
||||
EventPasswordPrompt EventType = "password_prompt"
|
||||
// EventSteerConsumed fires when one or more steering messages have been
|
||||
// injected into the agent turn via PrepareStep.
|
||||
EventSteerConsumed EventType = "steer_consumed"
|
||||
@@ -108,6 +110,38 @@ func parseToolArgs(toolArgs string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finish reason constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Finish reasons reported by the LLM provider on a completed turn. These
|
||||
// mirror fantasy.FinishReason string values so comparisons against
|
||||
// TurnEndEvent.StopReason / TurnResult.StopReason are stable across
|
||||
// providers.
|
||||
const (
|
||||
// FinishReasonStop: the model produced a natural stop (e.g. stop sequence
|
||||
// or end-of-turn signal).
|
||||
FinishReasonStop = "stop"
|
||||
// FinishReasonLength: the model hit the configured max_output_tokens
|
||||
// budget. The response is truncated. Surface this to the user and
|
||||
// consider raising --max-tokens / KIT_MAX_TOKENS / modelSettings[...]
|
||||
// .maxTokens.
|
||||
FinishReasonLength = "length"
|
||||
// FinishReasonToolCalls: the model stopped to emit tool calls (normal
|
||||
// mid-turn state during agentic loops).
|
||||
FinishReasonToolCalls = "tool-calls"
|
||||
// FinishReasonContentFilter: the provider's safety filter stopped
|
||||
// generation.
|
||||
FinishReasonContentFilter = "content-filter"
|
||||
// FinishReasonError: the model stopped because of an error.
|
||||
FinishReasonError = "error"
|
||||
// FinishReasonOther: provider-specific reason that doesn't map to any of
|
||||
// the above.
|
||||
FinishReasonOther = "other"
|
||||
// FinishReasonUnknown: the provider didn't report a finish reason.
|
||||
FinishReasonUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concrete event structs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,9 +156,13 @@ func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
|
||||
|
||||
// TurnEndEvent fires after the agent finishes processing.
|
||||
type TurnEndEvent struct {
|
||||
Response string
|
||||
Error error
|
||||
StopReason string // "end_turn", "max_tokens", "tool_use", "error", etc.
|
||||
Response string
|
||||
Error error
|
||||
// StopReason is the LLM provider's finish reason for the final step of
|
||||
// the turn. Compare against the FinishReason* constants — in particular,
|
||||
// FinishReasonLength indicates the response was truncated because the
|
||||
// agent hit its max_output_tokens budget.
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -299,6 +337,26 @@ type SteerConsumedEvent struct {
|
||||
// EventType implements Event.
|
||||
func (e SteerConsumedEvent) EventType() EventType { return EventSteerConsumed }
|
||||
|
||||
// PasswordPromptEvent fires when a sudo command needs a password.
|
||||
// The TUI should display a password prompt and send the result back via ResponseCh.
|
||||
type PasswordPromptEvent struct {
|
||||
// Prompt is the message to display to the user.
|
||||
Prompt string
|
||||
// ResponseCh receives the password from the TUI.
|
||||
// The TUI must send exactly one value: (password, false) for submit
|
||||
// or ("", true) for cancel.
|
||||
ResponseCh chan<- PasswordPromptResponse
|
||||
}
|
||||
|
||||
// PasswordPromptResponse carries the password prompt result.
|
||||
type PasswordPromptResponse struct {
|
||||
Password string
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
func (e PasswordPromptEvent) EventType() EventType { return EventPasswordPrompt }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EventBus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+250
-27
@@ -51,6 +51,7 @@ type Kit struct {
|
||||
bufferedLogger *tools.BufferedDebugLogger
|
||||
authHandler MCPAuthHandler // OAuth handler for remote MCP servers (may need Close)
|
||||
opts *Options // stored for reload operations (skills, etc.)
|
||||
mcpConfig *config.Config // loaded MCP/server config, shared with subagents
|
||||
|
||||
// hasCustomSystemPrompt is true when the user explicitly configured a
|
||||
// system prompt (via --system-prompt flag, config file, or SDK option).
|
||||
@@ -810,6 +811,29 @@ func (m *Kit) ExecuteCompletion(ctx context.Context, req extensions.CompleteRequ
|
||||
// Options configures Kit creation with optional overrides for model,
|
||||
// prompts, configuration, and behavior settings. All fields are optional
|
||||
// and will use CLI defaults if not specified.
|
||||
//
|
||||
// Global viper state warning:
|
||||
// Options are applied by [New] via [viper.Set] calls against viper's
|
||||
// process-global store. This store is shared with every downstream reader
|
||||
// (e.g. [Kit.SetModel], [Kit.GetThinkingLevel], BuildProviderConfig, and
|
||||
// any other code path that calls viper.Get*). Two consequences:
|
||||
//
|
||||
// 1. Kit instances are NOT isolated from each other within a single
|
||||
// process. Values set by the second New() call overwrite the first,
|
||||
// and any code that later reads viper will see the most recent Set.
|
||||
// 2. Fields left at the zero value do NOT clear prior viper state; they
|
||||
// simply skip the viper.Set. Callers that need a clean slate between
|
||||
// constructions should invoke viper.Reset() (the test suite uses a
|
||||
// private resetViper() helper that wraps it) before the next New().
|
||||
//
|
||||
// Recommended usage: create one Kit per process, or reset viper between
|
||||
// constructions. Concurrent calls to New are serialized internally by
|
||||
// [viperInitMu], but that mutex does not prevent later viper reads (from
|
||||
// a different Kit) from observing mutated keys.
|
||||
//
|
||||
// TODO: refactor New to use a per-instance *viper.Viper (constructed via
|
||||
// viper.New()) so each Kit owns its own isolated config store and Options
|
||||
// no longer leak through the global singleton.
|
||||
type Options struct {
|
||||
Model string // Override model (e.g., "anthropic/claude-sonnet-4-5-20250929")
|
||||
SystemPrompt string // Override system prompt
|
||||
@@ -820,6 +844,76 @@ type Options struct {
|
||||
Tools []Tool // Custom tool set. If empty, AllTools() is used.
|
||||
ExtraTools []Tool // Additional tools added alongside core/MCP/extension tools.
|
||||
|
||||
// Generation parameters. These override the corresponding values from
|
||||
// .kit.yml / KIT_* environment variables. Leaving a field at its
|
||||
// zero/nil value means "use the configured default", which in turn
|
||||
// falls back to per-model defaults (modelSettings / customModels) and
|
||||
// finally to a last-resort SDK floor of 8192 for MaxTokens (matching
|
||||
// the CLI --max-tokens default; sampling params fall through to
|
||||
// provider-level defaults).
|
||||
//
|
||||
// Pointer types are used for sampling parameters so the SDK can
|
||||
// distinguish "explicitly set to 0" from "leave alone".
|
||||
|
||||
// MaxTokens overrides the maximum output tokens per LLM response.
|
||||
// 0 = let the precedence chain resolve a value (env → config →
|
||||
// per-model → 8192 SDK floor, matching the CLI default). Setting a
|
||||
// non-zero value here suppresses automatic right-sizing, matching
|
||||
// the CLI's --max-tokens flag semantics. Bump this when generating
|
||||
// long outputs (HTML artifacts, large refactors, etc.) to avoid
|
||||
// silent truncation mid-tool-call. The cap also applies after
|
||||
// model switches via [Kit.SetModel].
|
||||
MaxTokens int
|
||||
|
||||
// ThinkingLevel sets the reasoning effort for models that support
|
||||
// extended thinking. Valid values: "off", "low", "medium", "high".
|
||||
// "" = let the precedence chain resolve a level (env → config →
|
||||
// per-model → "off"). Use [Kit.SetThinkingLevel] to change at
|
||||
// runtime.
|
||||
ThinkingLevel string
|
||||
|
||||
// Temperature controls sampling randomness (typically 0.0–2.0).
|
||||
// nil = leave provider/per-model default in place. Pointer type
|
||||
// so explicit 0.0 (deterministic) is distinguishable from "unset".
|
||||
Temperature *float32
|
||||
|
||||
// TopP is the nucleus-sampling cutoff (0.0–1.0).
|
||||
// nil = leave provider/per-model default in place.
|
||||
TopP *float32
|
||||
|
||||
// TopK limits sampling to the top K tokens.
|
||||
// nil = leave provider/per-model default in place.
|
||||
TopK *int32
|
||||
|
||||
// FrequencyPenalty discourages repeated tokens (OpenAI-family models).
|
||||
// nil = leave provider/per-model default in place.
|
||||
FrequencyPenalty *float32
|
||||
|
||||
// PresencePenalty discourages repeating topics (OpenAI-family models).
|
||||
// nil = leave provider/per-model default in place.
|
||||
PresencePenalty *float32
|
||||
|
||||
// Provider configuration. These override values normally read from
|
||||
// .kit.yml or provider-specific environment variables. Useful when
|
||||
// loading credentials from a secrets manager, pointing at custom
|
||||
// OpenAI-compatible endpoints (LiteLLM, vLLM, Azure OpenAI, internal
|
||||
// proxies), or running against self-hosted infrastructure.
|
||||
|
||||
// ProviderAPIKey overrides the API key used to authenticate with the
|
||||
// model provider. "" = use the value from config or the
|
||||
// provider-specific environment variable.
|
||||
ProviderAPIKey string
|
||||
|
||||
// ProviderURL overrides the provider endpoint. "" = use the provider's
|
||||
// default URL.
|
||||
ProviderURL string
|
||||
|
||||
// TLSSkipVerify disables TLS certificate verification on provider
|
||||
// HTTP clients. Only set this for self-signed certificates in
|
||||
// development. Once enabled here it cannot be disabled via Options
|
||||
// (use the config file or env var to opt back out).
|
||||
TLSSkipVerify bool
|
||||
|
||||
// SkipConfig, when true, skips loading .kit.yml configuration files.
|
||||
// Viper defaults (setSDKDefaults) and environment variables (KIT_*)
|
||||
// are still applied. Use this for fully programmatic configuration.
|
||||
@@ -849,6 +943,13 @@ type Options struct {
|
||||
// (e.g. AGENTS.md) from the working directory.
|
||||
NoContextFiles bool
|
||||
|
||||
// MCPConfig provides a pre-loaded MCP configuration. When set,
|
||||
// LoadAndValidateConfig is skipped during Kit creation — avoiding
|
||||
// viper access entirely. This is set automatically for in-process
|
||||
// subagents (inheriting the parent's loaded config) and can be used
|
||||
// by SDK consumers who build config programmatically.
|
||||
MCPConfig *config.Config
|
||||
|
||||
// InProcessMCPServers registers mcp-go servers that run in the same
|
||||
// process. Each key is the server name (used to prefix tool names, e.g.
|
||||
// "docs__search"). The value must be a *[server.MCPServer].
|
||||
@@ -879,15 +980,23 @@ type Options struct {
|
||||
Debug bool
|
||||
|
||||
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured with
|
||||
// OAuth support. If the server returns a 401, the handler is invoked to
|
||||
// let the user authorize via browser.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured
|
||||
// with OAuth support. If the server returns a 401, the handler is
|
||||
// invoked to let the user authorize.
|
||||
//
|
||||
// If nil, a [DefaultMCPAuthHandler] is created automatically — opening the
|
||||
// system browser and listening on a local callback server.
|
||||
// If nil, OAuth is disabled: remote MCP servers requiring authorization
|
||||
// will fail to connect and the underlying authorization-required error
|
||||
// is surfaced to the caller. The SDK deliberately does not construct a
|
||||
// default handler — doing so would bind a local TCP port and trigger
|
||||
// presentation I/O (browser open, stderr writes) without the consumer
|
||||
// opting in, which is wrong for library, daemon, or web-app embedders.
|
||||
//
|
||||
// Set to a custom implementation to control the authorization UX (e.g.
|
||||
// display a URL in a custom UI, redirect to a web app, etc.).
|
||||
// CLI consumers: pass [NewCLIMCPAuthHandler] to get the standard
|
||||
// "open browser + print status" behavior.
|
||||
//
|
||||
// Custom UX: implement [MCPAuthHandler] directly, or use
|
||||
// [DefaultMCPAuthHandler] and set its OnAuthURL hook to plug in your
|
||||
// own presentation (TUI modal, QR code, web redirect, etc.).
|
||||
MCPAuthHandler MCPAuthHandler
|
||||
|
||||
// MCPTokenStoreFactory, if non-nil, is called to create a token store for
|
||||
@@ -971,14 +1080,29 @@ func InitTreeSession(opts *Options) (*session.TreeManager, error) {
|
||||
return session.CreateTreeSession(sessionDir)
|
||||
}
|
||||
|
||||
// viperInitMu serializes viper writes during [New]. Viper's global state
|
||||
// is not thread-safe, so concurrent calls (e.g. parallel subagent spawns)
|
||||
// must not overlap the Set/Get window. Note that this mutex only protects
|
||||
// the construction window — it does not isolate long-lived Kit instances
|
||||
// from each other. See the "Global viper state warning" on [Options].
|
||||
var viperInitMu sync.Mutex
|
||||
|
||||
// New creates a Kit instance using the same initialization as the CLI.
|
||||
// It loads configuration, initializes MCP servers, creates the LLM model, and
|
||||
// sets up the agent for interaction. Returns an error if initialization fails.
|
||||
// viperInitMu serializes viper writes during kit.New(). Viper's global state
|
||||
// is not thread-safe, so concurrent calls (e.g. parallel subagent spawns)
|
||||
// must not overlap the Set()/Get() window.
|
||||
var viperInitMu sync.Mutex
|
||||
|
||||
//
|
||||
// Global viper state warning: fields on [Options] are applied by calling
|
||||
// [viper.Set] on viper's process-global store. As a result, two Kits
|
||||
// constructed in the same process are NOT isolated: the second New
|
||||
// overwrites viper keys set by the first, and any downstream reader
|
||||
// (e.g. [Kit.SetModel], [Kit.GetThinkingLevel]) will observe the most
|
||||
// recent value. Callers that need multiple independent Kits should call
|
||||
// viper.Reset() between constructions, or avoid constructing more than
|
||||
// one Kit per process. Writes during New are serialized by [viperInitMu].
|
||||
//
|
||||
// TODO: refactor to use a per-call viper.New() instance so each Kit owns
|
||||
// its own isolated config store and Options stop leaking through the
|
||||
// global singleton.
|
||||
func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
if opts == nil {
|
||||
opts = &Options{}
|
||||
@@ -1039,6 +1163,47 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
}
|
||||
viper.Set("stream", opts.Streaming)
|
||||
|
||||
// Generation parameter overrides. Each Options field, when set,
|
||||
// is pushed into viper here so the existing downstream code
|
||||
// (BuildProviderConfig, SetModel, modelSettings lookups) picks
|
||||
// it up uniformly. Pointer-typed sampling params use viper.Set
|
||||
// only when non-nil so that nil means "leave provider/per-model
|
||||
// default in place" (BuildProviderConfig keys off viper.IsSet).
|
||||
if opts.MaxTokens > 0 {
|
||||
viper.Set("max-tokens", opts.MaxTokens)
|
||||
}
|
||||
if opts.ThinkingLevel != "" {
|
||||
viper.Set("thinking-level", opts.ThinkingLevel)
|
||||
}
|
||||
if opts.Temperature != nil {
|
||||
viper.Set("temperature", *opts.Temperature)
|
||||
}
|
||||
if opts.TopP != nil {
|
||||
viper.Set("top-p", *opts.TopP)
|
||||
}
|
||||
if opts.TopK != nil {
|
||||
viper.Set("top-k", *opts.TopK)
|
||||
}
|
||||
if opts.FrequencyPenalty != nil {
|
||||
viper.Set("frequency-penalty", *opts.FrequencyPenalty)
|
||||
}
|
||||
if opts.PresencePenalty != nil {
|
||||
viper.Set("presence-penalty", *opts.PresencePenalty)
|
||||
}
|
||||
|
||||
// Provider overrides. TLSSkipVerify only takes effect when true —
|
||||
// callers wanting to force-disable should use the config file or
|
||||
// env var instead.
|
||||
if opts.ProviderAPIKey != "" {
|
||||
viper.Set("provider-api-key", opts.ProviderAPIKey)
|
||||
}
|
||||
if opts.ProviderURL != "" {
|
||||
viper.Set("provider-url", opts.ProviderURL)
|
||||
}
|
||||
if opts.TLSSkipVerify {
|
||||
viper.Set("tls-skip-verify", true)
|
||||
}
|
||||
|
||||
// Resolve working directory for context/skill discovery.
|
||||
cwd = opts.SessionDir
|
||||
if cwd == "" {
|
||||
@@ -1124,6 +1289,17 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
if pcErr != nil {
|
||||
return fmt.Errorf("failed to build provider config: %w", pcErr)
|
||||
}
|
||||
|
||||
// SDK last-resort max-tokens floor. When nothing — Options, env,
|
||||
// config, nor a per-model default — supplied a value, we land on
|
||||
// zero here (viper.GetInt returns 0 for unset keys). Apply the
|
||||
// SDK default directly on the struct rather than via viper so
|
||||
// viper.IsSet("max-tokens") stays false: downstream right-sizing
|
||||
// can still raise this toward the model's known output ceiling,
|
||||
// and per-model modelSettings[...].maxTokens can still win.
|
||||
if providerConfig.MaxTokens == 0 && opts.MaxTokens == 0 {
|
||||
providerConfig.MaxTokens = sdkDefaultMaxTokens
|
||||
}
|
||||
modelString = viper.GetString("model")
|
||||
debug = viper.GetBool("debug")
|
||||
noExtensions = opts.NoExtensions || viper.GetBool("no-extensions")
|
||||
@@ -1136,8 +1312,11 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
}
|
||||
// ---- viperInitMu released — heavy I/O below runs concurrently ----
|
||||
|
||||
// Load MCP configuration. Use pre-loaded config if provided via CLI options.
|
||||
if opts.CLI != nil && opts.CLI.MCPConfig != nil {
|
||||
// Load MCP configuration. Use pre-loaded config if provided directly,
|
||||
// via CLI options, or load from viper as a last resort.
|
||||
if opts.MCPConfig != nil {
|
||||
mcpConfig = opts.MCPConfig
|
||||
} else if opts.CLI != nil && opts.CLI.MCPConfig != nil {
|
||||
mcpConfig = opts.CLI.MCPConfig
|
||||
}
|
||||
if mcpConfig == nil {
|
||||
@@ -1191,20 +1370,19 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
OnMCPServerLoaded: opts.OnMCPServerLoaded,
|
||||
}
|
||||
|
||||
// Set up OAuth handler for remote MCP servers.
|
||||
// Set up OAuth handler for remote MCP servers. The SDK does not create
|
||||
// a default handler: auto-construction would bind a local TCP port and
|
||||
// (historically) shell out to a browser without the consumer asking,
|
||||
// which is a surprise for library/daemon/web-app embedders. Consumers
|
||||
// that want CLI behavior pass a [CLIMCPAuthHandler] explicitly; other
|
||||
// consumers implement [MCPAuthHandler] themselves. If nil, remote MCP
|
||||
// servers requiring OAuth will fail to connect with the underlying
|
||||
// authorization-required error surfaced to the caller.
|
||||
//
|
||||
// The SDK MCPAuthHandler interface is structurally identical to
|
||||
// tools.MCPAuthHandler, so any implementation satisfies both.
|
||||
if opts.MCPAuthHandler != nil {
|
||||
setupOpts.AuthHandler = opts.MCPAuthHandler
|
||||
} else {
|
||||
// Create a default handler that opens the system browser.
|
||||
defaultHandler, authErr := NewDefaultMCPAuthHandler()
|
||||
if authErr != nil {
|
||||
// Non-fatal: OAuth just won't be available for remote servers.
|
||||
log.Printf("WARN Failed to create OAuth handler; remote MCP servers requiring auth will fail: %v", authErr)
|
||||
} else {
|
||||
setupOpts.AuthHandler = defaultHandler
|
||||
}
|
||||
}
|
||||
|
||||
// Set up custom token store factory for MCP OAuth tokens.
|
||||
@@ -1258,6 +1436,7 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
bufferedLogger: agentResult.BufferedLogger,
|
||||
authHandler: setupOpts.AuthHandler,
|
||||
opts: opts,
|
||||
mcpConfig: mcpConfig,
|
||||
hasCustomSystemPrompt: hasCustomSystemPrompt,
|
||||
beforeToolCall: beforeToolCall,
|
||||
afterToolResult: afterToolResult,
|
||||
@@ -1439,8 +1618,9 @@ type TurnResult struct {
|
||||
Response string
|
||||
|
||||
// StopReason indicates why the turn ended. Derived from the LLM
|
||||
// provider's finish reason: "stop", "length" (max tokens), "tool-calls",
|
||||
// "content-filter", "error", "other", "unknown".
|
||||
// provider's finish reason: FinishReasonStop, FinishReasonLength (max
|
||||
// output tokens reached), FinishReasonToolCalls, FinishReasonContentFilter,
|
||||
// FinishReasonError, FinishReasonOther, FinishReasonUnknown.
|
||||
StopReason string
|
||||
|
||||
// SessionID is the UUID of the session this turn belongs to.
|
||||
@@ -1582,13 +1762,15 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
|
||||
tools = SubagentTools()
|
||||
}
|
||||
|
||||
// Create child Kit instance.
|
||||
// Create child Kit instance. Pass the parent's loaded MCP config to
|
||||
// avoid re-reading viper (which races with concurrent subagent spawns).
|
||||
childOpts := &Options{
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Tools: tools,
|
||||
NoSession: cfg.NoSession,
|
||||
Quiet: true,
|
||||
MCPConfig: m.mcpConfig,
|
||||
}
|
||||
child, err := New(ctx, childOpts)
|
||||
if err != nil {
|
||||
@@ -1809,6 +1991,18 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
|
||||
CacheWriteTokens: uint64(cacheCreationTokens),
|
||||
})
|
||||
},
|
||||
// Password prompt handler for sudo commands
|
||||
func(prompt string) (string, bool) {
|
||||
// Emit event to TUI and wait for response via channel
|
||||
responseCh := make(chan PasswordPromptResponse, 1)
|
||||
m.events.emit(PasswordPromptEvent{
|
||||
Prompt: prompt,
|
||||
ResponseCh: responseCh,
|
||||
})
|
||||
// Wait for response (TUI will send password or cancel)
|
||||
resp := <-responseCh
|
||||
return resp.Password, resp.Cancelled
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2223,6 +2417,35 @@ func (m *Kit) GetTools() []Tool {
|
||||
return m.agent.GetTools()
|
||||
}
|
||||
|
||||
// MaxTokens returns the effective max output tokens currently configured for
|
||||
// the agent. This is the value actually sent to the LLM provider on each
|
||||
// request, after CLI/env/config resolution, per-model overrides, model-aware
|
||||
// right-sizing, and any Anthropic thinking-budget adjustments.
|
||||
//
|
||||
// Returns 0 when the active provider suppresses the max_output_tokens
|
||||
// parameter (e.g. OpenAI Codex OAuth) or when no model is configured yet.
|
||||
// A non-zero value is the number that will cause a FinishReasonLength
|
||||
// truncation if the model tries to generate beyond it.
|
||||
func (m *Kit) MaxTokens() int {
|
||||
if m.agent == nil {
|
||||
return 0
|
||||
}
|
||||
return m.agent.GetMaxTokens()
|
||||
}
|
||||
|
||||
// MaxOutputLimit returns the catalog-reported output ceiling for the current
|
||||
// model in tokens, or 0 when the model isn't in the registry (custom models,
|
||||
// new releases, Ollama, etc.). Pair with MaxTokens() to detect when the agent
|
||||
// is configured well below what the model supports and surface a hint to the
|
||||
// user.
|
||||
func (m *Kit) MaxOutputLimit() int {
|
||||
info := m.GetModelInfo()
|
||||
if info == nil {
|
||||
return 0
|
||||
}
|
||||
return info.Limit.Output
|
||||
}
|
||||
|
||||
// extractFileParts returns all FilePart entries from a message's Content.
|
||||
// Used to preserve image attachments when replacing user message text.
|
||||
func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
@@ -54,6 +56,225 @@ func TestNewWithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWithGenerationOptions verifies that the SDK-only generation
|
||||
// parameter overrides on Options propagate all the way through to the
|
||||
// agent without requiring any viper.Set workarounds in caller code.
|
||||
func TestNewWithGenerationOptions(t *testing.T) {
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
||||
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// MaxTokens override — keep ThinkingLevel off so Anthropic's thinking
|
||||
// budget doesn't auto-bump MaxTokens above what we configured.
|
||||
t.Run("MaxTokens", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
const want = 12345
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
MaxTokens: want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
if got := host.MaxTokens(); got != want {
|
||||
t.Errorf("Options.MaxTokens=%d did not propagate; Kit.MaxTokens()=%d", want, got)
|
||||
}
|
||||
if !viper.IsSet("max-tokens") {
|
||||
t.Error("viper.IsSet(\"max-tokens\") should be true after MaxTokens override")
|
||||
}
|
||||
})
|
||||
|
||||
// ThinkingLevel override — verified via the public getter, which
|
||||
// reads back the configured (not provider-derived) level.
|
||||
t.Run("ThinkingLevel", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
const want = "high"
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
ThinkingLevel: want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
if got := host.GetThinkingLevel(); got != want {
|
||||
t.Errorf("Options.ThinkingLevel=%q did not propagate; Kit.GetThinkingLevel()=%q", want, got)
|
||||
}
|
||||
})
|
||||
|
||||
// Temperature override — pointer semantics let callers distinguish
|
||||
// "explicitly 0.0" from "unset", which we assert by pushing a distinct
|
||||
// value and reading it back off viper's merged state.
|
||||
t.Run("Temperature", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
want := float32(0.12345)
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
Temperature: &want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
if !viper.IsSet("temperature") {
|
||||
t.Fatal("viper.IsSet(\"temperature\") should be true after Temperature override")
|
||||
}
|
||||
if got := float32(viper.GetFloat64("temperature")); got != want {
|
||||
t.Errorf("Options.Temperature=%v did not propagate; viper=%v", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewPreservesIsSetSemantics verifies that creating a Kit WITHOUT
|
||||
// populating the generation-param Options fields does NOT mark those
|
||||
// keys as explicitly set in viper. This is the precedence contract
|
||||
// that per-model defaults (ApplyModelSettings) and right-sizing
|
||||
// (rightSizeMaxTokens) rely on.
|
||||
//
|
||||
// Previously setSDKDefaults() used viper.SetDefault() for every param,
|
||||
// which caused viper.IsSet() to return true for all of them — silently
|
||||
// suppressing per-model defaults and pinning max-tokens at 4096 even
|
||||
// on models with much larger output limits.
|
||||
func TestNewPreservesIsSetSemantics(t *testing.T) {
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
||||
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
defer resetViper()
|
||||
|
||||
ctx := context.Background()
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
NoSession: true,
|
||||
SkipConfig: true, // isolate from any ~/.kit.yml values
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
// These keys must remain "unset" from viper's perspective so the
|
||||
// downstream isExplicitlySet() checks allow per-model defaults to
|
||||
// take effect.
|
||||
checkKeys := []string{
|
||||
"max-tokens",
|
||||
"temperature",
|
||||
"top-p",
|
||||
"top-k",
|
||||
"frequency-penalty",
|
||||
"presence-penalty",
|
||||
"thinking-level",
|
||||
}
|
||||
|
||||
// With SkipConfig: true, InitConfig() is not invoked, so viper has
|
||||
// no env-var bindings registered. Any IsSet() here would come purely
|
||||
// from SDK-side SetDefault/Set calls — which is exactly what this
|
||||
// test is guarding against.
|
||||
for _, k := range checkKeys {
|
||||
if viper.IsSet(k) {
|
||||
t.Errorf("viper.IsSet(%q) == true when no Options field set it "+
|
||||
"(SDK defaults must not corrupt IsSet semantics)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWithProviderOptions verifies that programmatic provider overrides
|
||||
// (API key, URL) take effect without env vars or config files, and that
|
||||
// Options.ProviderAPIKey *wins* over any pre-existing viper state.
|
||||
func TestNewWithProviderOptions(t *testing.T) {
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
||||
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("succeeds with API key from Options", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
NoSession: true,
|
||||
ProviderAPIKey: apiKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit with ProviderAPIKey option: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
if got := viper.GetString("provider-api-key"); got != apiKey {
|
||||
t.Errorf("Options.ProviderAPIKey did not propagate to viper; got %q (len=%d)", got, len(got))
|
||||
}
|
||||
})
|
||||
|
||||
// Override precedence: even when viper already holds a different
|
||||
// provider-api-key value (as it would if a config file or earlier
|
||||
// Set() call populated one), Options.ProviderAPIKey must win.
|
||||
t.Run("Options override beats pre-existing viper state", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
viper.Set("provider-api-key", "sk-config-file-placeholder")
|
||||
|
||||
want := "sk-from-options-override"
|
||||
// Use an OpenAI-flavored model so the validation path accepts
|
||||
// the placeholder without attempting a real Anthropic handshake.
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "openai/gpt-4o-mini",
|
||||
Quiet: true,
|
||||
NoSession: true,
|
||||
NoExtensions: true,
|
||||
DisableCoreTools: true,
|
||||
ProviderAPIKey: want,
|
||||
})
|
||||
// Creation may still fail if the model registry is strict, but
|
||||
// we only care that the override reached viper before any
|
||||
// provider handshake happened.
|
||||
if host != nil {
|
||||
defer func() { _ = host.Close() }()
|
||||
}
|
||||
_ = err
|
||||
|
||||
if got := viper.GetString("provider-api-key"); got != want {
|
||||
t.Errorf("Options.ProviderAPIKey did not override pre-existing viper value; got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
// ProviderURL override must also reach viper.
|
||||
t.Run("ProviderURL propagates", func(t *testing.T) {
|
||||
defer resetViper()
|
||||
|
||||
const want = "https://custom.example.com/v1"
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
Quiet: true,
|
||||
NoSession: true,
|
||||
ProviderURL: want,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Kit with ProviderURL option: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
if got := viper.GetString("provider-url"); got != want {
|
||||
t.Errorf("Options.ProviderURL did not propagate; got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionManagement(t *testing.T) {
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" {
|
||||
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
|
||||
@@ -81,3 +302,7 @@ func TestSessionManagement(t *testing.T) {
|
||||
t.Error("Expected non-empty session ID")
|
||||
}
|
||||
}
|
||||
|
||||
// resetViper wipes viper's global state so a test case doesn't leak
|
||||
// viper.Set() calls into the next one. Used via defer in subtests.
|
||||
func resetViper() { viper.Reset() }
|
||||
|
||||
+45
-45
@@ -5,18 +5,18 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MCPAuthHandler handles OAuth authorization for MCP servers.
|
||||
// Implementations control the user experience — opening a browser, showing a
|
||||
// prompt, displaying a URL, etc.
|
||||
// prompt, displaying a URL, posting to a message bus, etc.
|
||||
//
|
||||
// The default implementation ([DefaultMCPAuthHandler]) opens the system browser
|
||||
// and starts a local HTTP callback server to receive the authorization code.
|
||||
// [DefaultMCPAuthHandler] provides the transport mechanics (port reservation
|
||||
// and callback server) but performs no user-facing I/O on its own; consumers
|
||||
// wire presentation via [DefaultMCPAuthHandler.OnAuthURL] or implement
|
||||
// MCPAuthHandler from scratch.
|
||||
type MCPAuthHandler interface {
|
||||
// RedirectURI returns the OAuth redirect URI that the callback server
|
||||
// will listen on. This is called during MCP transport setup — before any
|
||||
@@ -37,23 +37,44 @@ type MCPAuthHandler interface {
|
||||
HandleAuth(ctx context.Context, serverName string, authURL string) (callbackURL string, err error)
|
||||
}
|
||||
|
||||
// DefaultMCPAuthHandler opens the system browser and starts a local HTTP
|
||||
// callback server to receive the OAuth authorization code. It eagerly reserves
|
||||
// a TCP port on construction so [RedirectURI] is stable for the lifetime of
|
||||
// the handler.
|
||||
// DefaultMCPAuthHandler provides the transport mechanics of an OAuth flow —
|
||||
// reserving a local TCP port and running a one-shot HTTP callback server —
|
||||
// without making any user-experience decisions. It performs no browser opens,
|
||||
// no printing, no TUI calls; consumers attach presentation by setting
|
||||
// [DefaultMCPAuthHandler.OnAuthURL] or by wrapping the handler.
|
||||
//
|
||||
// Create instances with [NewDefaultMCPAuthHandler] (random port) or
|
||||
// [NewDefaultMCPAuthHandlerWithPort] (explicit port).
|
||||
// The handler eagerly reserves a TCP port on construction so [RedirectURI] is
|
||||
// stable for the lifetime of the handler. Create instances with
|
||||
// [NewDefaultMCPAuthHandler] (random port) or [NewDefaultMCPAuthHandlerWithPort]
|
||||
// (explicit port). Always call [DefaultMCPAuthHandler.Close] when done to
|
||||
// release the port.
|
||||
type DefaultMCPAuthHandler struct {
|
||||
listener net.Listener
|
||||
port int
|
||||
mu sync.Mutex // guards listener lifecycle
|
||||
|
||||
// OnAuthURL, if set, is invoked exactly once per [HandleAuth] call with
|
||||
// the authorization URL the user must visit. This is where consumers
|
||||
// plug in their UX: open a browser, print to stderr, post to a TUI
|
||||
// stream, render a QR code, etc. The handler performs no I/O on the
|
||||
// URL itself; if OnAuthURL is nil the URL is silently dropped and the
|
||||
// user has no way to complete the flow.
|
||||
//
|
||||
// OnAuthURL is called synchronously before the handler blocks on the
|
||||
// callback. It must not block indefinitely — long-running work should
|
||||
// be dispatched to a goroutine.
|
||||
OnAuthURL func(serverName, authURL string)
|
||||
}
|
||||
|
||||
// NewDefaultMCPAuthHandler creates a handler that listens on a random
|
||||
// available port on localhost. The port is reserved immediately so
|
||||
// [RedirectURI] returns a stable value. Call [DefaultMCPAuthHandler.Close]
|
||||
// when the handler is no longer needed to release the port.
|
||||
//
|
||||
// The returned handler has no OnAuthURL hook configured and will therefore
|
||||
// appear to hang on HandleAuth until the context deadline fires. Set
|
||||
// OnAuthURL before using the handler, or use a higher-level wrapper such
|
||||
// as [CLIMCPAuthHandler].
|
||||
func NewDefaultMCPAuthHandler() (*DefaultMCPAuthHandler, error) {
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
@@ -88,9 +109,9 @@ func (h *DefaultMCPAuthHandler) Port() int {
|
||||
return h.port
|
||||
}
|
||||
|
||||
// HandleAuth opens the system browser to authURL and waits for the OAuth
|
||||
// callback on the local server. It returns the full callback URL including
|
||||
// query parameters (code, state, etc.).
|
||||
// HandleAuth invokes [OnAuthURL] with the authorization URL (if configured)
|
||||
// and waits for the OAuth callback on the local server. It returns the full
|
||||
// callback URL including query parameters (code, state, etc.).
|
||||
//
|
||||
// If the context has no deadline, a default 2-minute timeout is applied.
|
||||
// The callback server is started for each HandleAuth call and shut down
|
||||
@@ -136,19 +157,13 @@ func (h *DefaultMCPAuthHandler) HandleAuth(ctx context.Context, serverName strin
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
// Start serving on the pre-reserved listener. We need to create a new
|
||||
// listener on the same port because http.Server.Serve takes ownership
|
||||
// and closes the listener when done. The original listener is kept open
|
||||
// to reserve the port; we create a second listener via SO_REUSEADDR
|
||||
// semantics (Go's default on most platforms) or, more reliably, we
|
||||
// temporarily release and re-acquire.
|
||||
//
|
||||
// Strategy: use the held listener directly for Serve. After Serve
|
||||
// returns (due to Shutdown), re-acquire the listener to keep the port
|
||||
// reserved for future HandleAuth calls.
|
||||
// Start serving on the pre-reserved listener. http.Server.Serve takes
|
||||
// ownership and closes the listener when Shutdown is called, so we
|
||||
// re-acquire a fresh listener on the same port in the deferred cleanup
|
||||
// below to keep the port reserved for subsequent HandleAuth calls.
|
||||
h.mu.Lock()
|
||||
serveListener := h.listener
|
||||
h.listener = nil // Serve will close it
|
||||
h.listener = nil
|
||||
h.mu.Unlock()
|
||||
|
||||
if serveListener == nil {
|
||||
@@ -184,10 +199,11 @@ func (h *DefaultMCPAuthHandler) HandleAuth(ctx context.Context, serverName strin
|
||||
}
|
||||
}()
|
||||
|
||||
// Open the system browser.
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
// Browser open is best-effort; the user can still navigate manually.
|
||||
_ = err
|
||||
// Surface the authorization URL to the consumer. This is the single
|
||||
// presentation seam: the SDK itself does not open browsers, print,
|
||||
// or otherwise touch the user's environment.
|
||||
if h.OnAuthURL != nil {
|
||||
h.OnAuthURL(serverName, authURL)
|
||||
}
|
||||
|
||||
// Wait for the callback, a server error, or context cancellation.
|
||||
@@ -214,22 +230,6 @@ func (h *DefaultMCPAuthHandler) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openBrowser opens the default system browser to the given URL. This is a
|
||||
// best-effort operation — errors are returned but callers typically ignore
|
||||
// them since the user can navigate manually.
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// oauthSuccessHTML is the HTML page returned to the browser after a
|
||||
// successful OAuth callback.
|
||||
const oauthSuccessHTML = `<!DOCTYPE html>
|
||||
|
||||
+47
-15
@@ -5,32 +5,49 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CLIMCPAuthHandler wraps a [DefaultMCPAuthHandler] and prints status messages
|
||||
// to a writer (typically stderr) so the user knows what's happening during
|
||||
// OAuth authorization. This is the handler used by the CLI/TUI binary.
|
||||
// CLIMCPAuthHandler is the MCP OAuth handler for CLI/TUI consumers. It wraps
|
||||
// a [DefaultMCPAuthHandler] and layers standard CLI behavior on top of the
|
||||
// underlying transport mechanics:
|
||||
//
|
||||
// For TUI integration, set NotifyFunc to route messages through the TUI's
|
||||
// event system instead of (or in addition to) the writer.
|
||||
// - Opens the authorization URL in the system browser
|
||||
// - Prints status messages (or routes them to a TUI via [NotifyFunc])
|
||||
//
|
||||
// Non-CLI consumers (web apps, daemons, custom TUIs) should not use this
|
||||
// handler; implement [MCPAuthHandler] directly or configure a
|
||||
// [DefaultMCPAuthHandler] with a custom OnAuthURL instead.
|
||||
type CLIMCPAuthHandler struct {
|
||||
inner *DefaultMCPAuthHandler
|
||||
w io.Writer
|
||||
|
||||
// NotifyFunc, when set, is called with status messages instead of writing
|
||||
// to the writer. This allows the TUI to display system messages in the
|
||||
// chat stream. If nil, messages are written to w.
|
||||
// NotifyFunc, when set, is called with status messages instead of
|
||||
// writing to the writer. This allows the TUI to display system
|
||||
// messages in the chat stream. If nil, messages are written to w.
|
||||
NotifyFunc func(serverName, message string)
|
||||
}
|
||||
|
||||
// NewCLIMCPAuthHandler creates a CLI auth handler that prints status messages
|
||||
// to stderr and delegates the actual OAuth flow to a [DefaultMCPAuthHandler].
|
||||
// to stderr, opens the authorization URL in the system browser, and delegates
|
||||
// the callback-server mechanics to a [DefaultMCPAuthHandler].
|
||||
func NewCLIMCPAuthHandler() (*CLIMCPAuthHandler, error) {
|
||||
inner, err := NewDefaultMCPAuthHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CLIMCPAuthHandler{inner: inner, w: os.Stderr}, nil
|
||||
h := &CLIMCPAuthHandler{inner: inner, w: os.Stderr}
|
||||
// Wire the CLI presentation policy into the inner handler's hook.
|
||||
// This is the one place in the codebase where OAuth triggers a
|
||||
// browser open; the SDK core remains I/O-free.
|
||||
inner.OnAuthURL = func(serverName, authURL string) {
|
||||
h.notify(serverName, fmt.Sprintf("🔐 MCP server %q requires authentication. Opening browser...", serverName))
|
||||
h.notify(serverName, fmt.Sprintf(" If the browser doesn't open, visit:\n %s", authURL))
|
||||
// Browser open is best-effort; the user can still navigate manually.
|
||||
_ = openBrowser(authURL)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// RedirectURI returns the OAuth redirect URI from the inner handler.
|
||||
@@ -38,17 +55,15 @@ func (h *CLIMCPAuthHandler) RedirectURI() string {
|
||||
return h.inner.RedirectURI()
|
||||
}
|
||||
|
||||
// HandleAuth prints status messages and delegates to the inner handler.
|
||||
// HandleAuth delegates to the inner handler (which invokes OnAuthURL, runs
|
||||
// the callback server, and returns the full callback URL) and emits a final
|
||||
// success or failure notification.
|
||||
func (h *CLIMCPAuthHandler) HandleAuth(ctx context.Context, serverName string, authURL string) (string, error) {
|
||||
h.notify(serverName, fmt.Sprintf("🔐 MCP server %q requires authentication. Opening browser...", serverName))
|
||||
h.notify(serverName, fmt.Sprintf(" If the browser doesn't open, visit:\n %s", authURL))
|
||||
|
||||
callbackURL, err := h.inner.HandleAuth(ctx, serverName, authURL)
|
||||
if err != nil {
|
||||
h.notify(serverName, fmt.Sprintf("✗ Authentication failed for %q: %v", serverName, err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
h.notify(serverName, fmt.Sprintf("✓ Authenticated with %q", serverName))
|
||||
return callbackURL, nil
|
||||
}
|
||||
@@ -66,3 +81,20 @@ func (h *CLIMCPAuthHandler) notify(serverName, message string) {
|
||||
}
|
||||
_, _ = fmt.Fprintln(h.w, message)
|
||||
}
|
||||
|
||||
// openBrowser opens the system default browser at url. Intentionally
|
||||
// unexported: browser opening is CLI policy, not SDK surface. Consumers
|
||||
// that need similar behavior for their own UX should bring their own
|
||||
// helper (or use a third-party package like github.com/pkg/browser).
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "error" (on failure), "completed" (when LLM returns
|
||||
// empty stop reason), or the raw LLM provider value passed through
|
||||
// (e.g. "stop", "end_turn", "max_tokens", "tool_use").
|
||||
// (e.g. "stop", "length" (max output tokens hit), "tool-calls", "content-filter").
|
||||
// To detect errors, check e.StopReason == "error".
|
||||
// Do NOT compare against "completed" for success — instead check != "error".
|
||||
})
|
||||
|
||||
+130
-3
@@ -80,6 +80,23 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
Quiet: true, // suppress debug output
|
||||
Debug: true, // enable debug logging
|
||||
|
||||
// Generation parameters — override env/config/per-model defaults.
|
||||
// Leaving a field at its zero/nil value lets the precedence chain
|
||||
// resolve a value (KIT_* env → .kit.yml → modelSettings/customModels →
|
||||
// 8192 floor for MaxTokens, provider defaults for samplers).
|
||||
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
|
||||
ThinkingLevel: "medium", // "off", "low", "medium", "high" ("" = default)
|
||||
Temperature: ptrFloat32(0.2), // pointer so explicit 0.0 != unset
|
||||
TopP: nil, // nil = leave provider/per-model default
|
||||
TopK: nil, // nil = leave provider/per-model default
|
||||
FrequencyPenalty: nil,
|
||||
PresencePenalty: nil,
|
||||
|
||||
// Provider configuration — override env/config without viper.Set workarounds.
|
||||
ProviderAPIKey: "sk-...", // "" = use config / provider env var
|
||||
ProviderURL: "https://proxy.internal/v1", // "" = provider default endpoint
|
||||
TLSSkipVerify: false, // true only; can't force-disable via Options
|
||||
|
||||
// Session
|
||||
SessionDir: "/path/to/project", // base dir for session discovery (default: cwd)
|
||||
SessionPath: "/path/to/session.jsonl", // open specific session file
|
||||
@@ -108,7 +125,12 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
AutoCompact: true, // auto-compact near context limit
|
||||
CompactionOptions: &kit.CompactionOptions{...}, // nil = defaults
|
||||
|
||||
// MCP OAuth
|
||||
// MCP OAuth — both fields are opt-in. If MCPAuthHandler is nil,
|
||||
// remote MCP servers that require OAuth will fail to connect with
|
||||
// an authorization-required error instead of silently opening a
|
||||
// browser. CLI consumers use NewCLIMCPAuthHandler; other embedders
|
||||
// implement MCPAuthHandler or configure DefaultMCPAuthHandler.
|
||||
MCPAuthHandler: mcpAuthHandler, // nil = OAuth disabled
|
||||
MCPTokenStoreFactory: func(serverURL string) (kit.MCPTokenStore, error) {
|
||||
return myCustomStore(serverURL), nil // custom OAuth token storage
|
||||
},
|
||||
@@ -118,12 +140,34 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
"docs": mcpSrv, // *server.MCPServer from mcp-go — no subprocess needed
|
||||
},
|
||||
})
|
||||
|
||||
// Tiny helper to take the address of a literal for pointer fields.
|
||||
func ptrFloat32(v float32) *float32 { return &v }
|
||||
```
|
||||
|
||||
**Critical distinction**: `Tools` replaces ALL default tools (core + MCP + extension). `ExtraTools` adds tools alongside the defaults. Use `Tools` to restrict the agent's capabilities; use `ExtraTools` to extend them.
|
||||
|
||||
**In-process MCP servers** bypass subprocess spawning entirely. Pass `*server.MCPServer` instances from mcp-go via `InProcessMCPServers` or call `AddInProcessMCPServer()` at runtime.
|
||||
|
||||
### Generation & provider Options (cheat sheet)
|
||||
|
||||
| Field | Type | Empty/nil means | Notes |
|
||||
|-------|------|-----------------|-------|
|
||||
| `MaxTokens` | `int` | Auto-resolve (env → config → per-model → 8192 floor) | Non-zero suppresses `rightSizeMaxTokens` |
|
||||
| `ThinkingLevel` | `string` | Auto-resolve (→ `"off"`) | Valid: `"off"`, `"low"`, `"medium"`, `"high"` (and `"minimal"` for some providers) |
|
||||
| `Temperature` | `*float32` | Leave provider/per-model default | Pointer so explicit `0.0` ≠ unset |
|
||||
| `TopP` | `*float32` | Leave provider/per-model default | |
|
||||
| `TopK` | `*int32` | Leave provider/per-model default | |
|
||||
| `FrequencyPenalty` | `*float32` | Leave provider/per-model default | OpenAI-family |
|
||||
| `PresencePenalty` | `*float32` | Leave provider/per-model default | OpenAI-family |
|
||||
| `ProviderAPIKey` | `string` | Use config / provider env var | Overrides pre-existing viper state |
|
||||
| `ProviderURL` | `string` | Use provider default endpoint | Same base URL flag as `--provider-url` |
|
||||
| `TLSSkipVerify` | `bool` | — | Only effective when `true`; cannot force-disable via Options |
|
||||
|
||||
These fields eliminate the old `viper.Set("max-tokens", 16384)` dance many
|
||||
downstream embedders used to do before calling `kit.New()`. Everything is
|
||||
now discoverable via godoc on `kit.Options`.
|
||||
|
||||
---
|
||||
|
||||
## Prompt Methods
|
||||
@@ -270,6 +314,27 @@ unsub := host.Subscribe(func(e kit.Event) {
|
||||
| `reasoning_delta` | `ReasoningDeltaEvent` | `Delta` |
|
||||
| `step_usage` | `StepUsageEvent` | `InputTokens`, `OutputTokens`, `CacheReadTokens`, `CacheWriteTokens` |
|
||||
| `steer_consumed` | `SteerConsumedEvent` | `Count` |
|
||||
| `password_prompt` | `PasswordPromptEvent` | `Prompt`, `ResponseCh` |
|
||||
|
||||
**PasswordPromptEvent** (for sudo password handling):
|
||||
```go
|
||||
// PasswordPromptEvent fires when a sudo command needs a password.
|
||||
// The TUI should display a password prompt and send the result back via ResponseCh.
|
||||
type PasswordPromptEvent struct {
|
||||
// Prompt is the message to display to the user.
|
||||
Prompt string
|
||||
// ResponseCh receives the password from the TUI.
|
||||
// The TUI must send exactly one value: (password, false) for submit
|
||||
// or ("", true) for cancel.
|
||||
ResponseCh chan<- PasswordPromptResponse
|
||||
}
|
||||
|
||||
// PasswordPromptResponse carries the password prompt result.
|
||||
type PasswordPromptResponse struct {
|
||||
Password string
|
||||
Cancelled bool
|
||||
}
|
||||
```
|
||||
|
||||
### Tool kind constants
|
||||
|
||||
@@ -760,9 +825,65 @@ err = host.SubscribeMCPResource(ctx, "myserver", "file:///path/to/file")
|
||||
err = host.UnsubscribeMCPResource(ctx, "myserver", "file:///path/to/file")
|
||||
```
|
||||
|
||||
### MCP OAuth Authorization
|
||||
|
||||
When a remote MCP server requires OAuth, Kit runs the full authorization flow
|
||||
(dynamic client registration → PKCE → user consent → token exchange → token
|
||||
persistence) but delegates the **user-facing step** — displaying the
|
||||
authorization URL and receiving the callback — to an `MCPAuthHandler`.
|
||||
|
||||
The SDK ships three building blocks:
|
||||
|
||||
| Building block | When to use |
|
||||
|---|---|
|
||||
| **No handler** (`Options.MCPAuthHandler = nil`) | Default. OAuth is disabled; 401s from remote MCP servers surface as errors. Correct for library, daemon, and web-app embedders that don't want side effects. |
|
||||
| **`kit.NewCLIMCPAuthHandler()`** | CLI/TUI apps. Opens the system browser, prints status to stderr (or via `NotifyFunc`), runs a localhost callback server. This is what the `kit` binary uses. |
|
||||
| **`kit.NewDefaultMCPAuthHandler()` + `OnAuthURL`** | Custom UX. Get the transport mechanics (port reservation + callback server) from the SDK; wire your own presentation in the `OnAuthURL(serverName, authURL)` closure. |
|
||||
| **Implement `kit.MCPAuthHandler` directly** | Full control. No localhost binding — e.g. return the URL from an HTTP endpoint and have the consumer POST the callback URL back. |
|
||||
|
||||
**CLI-style embedder (browser + stderr):**
|
||||
|
||||
```go
|
||||
authHandler, err := kit.NewCLIMCPAuthHandler()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer authHandler.Close() // release the reserved port
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
```
|
||||
|
||||
**Custom UX embedder (TUI modal, QR code, web redirect, etc.):**
|
||||
|
||||
```go
|
||||
authHandler, _ := kit.NewDefaultMCPAuthHandler()
|
||||
authHandler.OnAuthURL = func(serverName, authURL string) {
|
||||
// Render the URL however you like — no browser or terminal assumptions.
|
||||
myUI.ShowAuthPrompt(serverName, authURL)
|
||||
}
|
||||
defer authHandler.Close()
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
```
|
||||
|
||||
**Important:** `DefaultMCPAuthHandler` with no `OnAuthURL` set will silently
|
||||
drop the authorization URL and block until the 2-minute callback timeout
|
||||
fires. Always set `OnAuthURL`, or use a higher-level wrapper like
|
||||
`CLIMCPAuthHandler`.
|
||||
|
||||
### MCP OAuth Token Storage
|
||||
|
||||
For remote MCP servers that use OAuth, you can provide a custom token store:
|
||||
Once authorization succeeds, the resulting access/refresh tokens are persisted
|
||||
by an `MCPTokenStore`. By default tokens are written to
|
||||
`$XDG_CONFIG_HOME/.kit/mcp_tokens.json` (fallback `~/.config/.kit/mcp_tokens.json`),
|
||||
keyed by server URL, with `0600` file permissions.
|
||||
|
||||
Provide a custom store for encrypted storage, database persistence, or
|
||||
in-memory-only flows:
|
||||
|
||||
```go
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
@@ -772,7 +893,7 @@ host, _ := kit.New(ctx, &kit.Options{
|
||||
})
|
||||
```
|
||||
|
||||
The `MCPTokenStore` interface requires `GetToken`/`SetToken`/`DeleteToken` methods. Return `kit.ErrMCPNoToken` from `GetToken` when no token is stored. When nil (default), tokens are persisted to `$XDG_CONFIG_HOME/.kit/mcp_tokens.json`.
|
||||
The `MCPTokenStore` interface requires `GetToken`/`SetToken`/`DeleteToken` methods. Return `kit.ErrMCPNoToken` from `GetToken` when no token is stored.
|
||||
|
||||
---
|
||||
|
||||
@@ -955,6 +1076,12 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType}
|
||||
kit.CompactionResult, kit.CompactionOptions
|
||||
|
||||
// MCP OAuth types
|
||||
kit.MCPAuthHandler // interface: RedirectURI() + HandleAuth(ctx, server, authURL) for OAuth UX
|
||||
kit.DefaultMCPAuthHandler // SDK-provided transport mechanics (port + callback server); set OnAuthURL hook
|
||||
kit.CLIMCPAuthHandler // CLI wrapper around DefaultMCPAuthHandler: opens browser, prints status
|
||||
kit.NewDefaultMCPAuthHandler() // random port, no UX side effects
|
||||
kit.NewDefaultMCPAuthHandlerWithPort() // fixed port (useful when registering a stable redirect URI)
|
||||
kit.NewCLIMCPAuthHandler() // CLI handler: browser + stderr + localhost callback
|
||||
kit.MCPTokenStore // interface for custom OAuth token storage
|
||||
kit.MCPToken // OAuth token struct (access, refresh, expiry)
|
||||
kit.MCPTokenStoreFactory // func(serverURL string) (MCPTokenStore, error)
|
||||
|
||||
@@ -52,7 +52,7 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `--max-tokens` | — | `4096` | Maximum tokens in response |
|
||||
| `--max-tokens` | — | `8192` | Base cap for output tokens. Auto-raised per-model up to 32768 when the model's catalog ceiling is higher and no explicit value is set. |
|
||||
| `--temperature` | — | `0.7` | Randomness 0.0–1.0 |
|
||||
| `--top-p` | — | `0.95` | Nucleus sampling 0.0–1.0 |
|
||||
| `--top-k` | — | `40` | Limit top K tokens |
|
||||
|
||||
@@ -18,7 +18,7 @@ Create `~/.kit.yml`:
|
||||
|
||||
```yaml
|
||||
model: anthropic/claude-sonnet-latest
|
||||
max-tokens: 4096
|
||||
max-tokens: 8192
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
```
|
||||
@@ -28,7 +28,7 @@ stream: true
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `model` | string | `anthropic/claude-sonnet-latest` | Model to use (provider/model format) |
|
||||
| `max-tokens` | int | `4096` | Maximum tokens in response |
|
||||
| `max-tokens` | int | `8192` | Base cap for output tokens. Auto-raised per-model up to 32768 when the model's catalog ceiling is higher and no explicit value is set. Use [`modelSettings[provider/model].maxTokens`](#per-model-settings) to override per-model. |
|
||||
| `temperature` | float | `0.7` | Randomness 0.0–1.0 |
|
||||
| `top-p` | float | `0.95` | Nucleus sampling 0.0–1.0 |
|
||||
| `top-k` | int | `40` | Limit top K tokens |
|
||||
@@ -175,10 +175,24 @@ modelSettings:
|
||||
| `thinkingLevel` | string | Thinking level override |
|
||||
| `systemPrompt` | string | Per-model system prompt (used when no explicit prompt is set) |
|
||||
|
||||
Settings from `modelSettings` and `customModels.params` act as model-level defaults — explicit CLI flags and global config values always take precedence.
|
||||
Settings from `modelSettings` and `customModels.params` act as model-level defaults — explicit CLI flags, `KIT_*` environment variables, global config values, and SDK `Options.*` fields all take precedence over them.
|
||||
|
||||
When switching models via `/model` or `SetModel()`, if the new model has a per-model system prompt and no custom global prompt was set, the per-model prompt automatically replaces the previous one.
|
||||
|
||||
### Precedence summary
|
||||
|
||||
For the generation and provider parameters documented above, the resolved value at runtime comes from the first source that sets it:
|
||||
|
||||
1. CLI flag (e.g. `--max-tokens`, `--temperature`, `--provider-api-key`)
|
||||
2. SDK `Options.X` when embedding Kit as a library (`kit.Options.MaxTokens`, `Temperature`, `ProviderAPIKey`, etc.)
|
||||
3. `KIT_*` environment variable (`KIT_MAX_TOKENS`, `KIT_TEMPERATURE`, ...)
|
||||
4. `.kit.yml` / `.kit.yaml` / `.kit.json` (project-local, then global)
|
||||
5. Per-model defaults (`modelSettings[provider/model]` / `customModels[...].params`)
|
||||
6. Provider-level defaults (e.g. Anthropic's own temperature default)
|
||||
7. SDK last-resort floor — currently an 8192 output-token ceiling matching the CLI `--max-tokens` default, auto-raised per-model up to 32768 when the model's catalog ceiling is higher
|
||||
|
||||
See the [SDK options reference](/sdk/options) for the full list of `kit.Options` fields that map to these keys.
|
||||
|
||||
## Theme configuration
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -37,7 +37,7 @@ internal/acpserver/ - ACP (Agent Client Protocol) server
|
||||
internal/clipboard/ - Cross-platform clipboard operations
|
||||
internal/compaction/ - Conversation compaction and summarization
|
||||
internal/config/ - Configuration management
|
||||
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
|
||||
internal/core/ - Built-in tools (bash with sudo password prompt, read, write, edit, grep, find, ls)
|
||||
internal/extensions/ - Yaegi extension system
|
||||
internal/kitsetup/ - Initial setup wizard
|
||||
internal/message/ - Message content types and structured content blocks
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
## Features
|
||||
|
||||
- **Multi-Provider LLM Support** — Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
|
||||
- **Built-in Core Tools** — bash, read, write, edit, grep, find, ls, subagent with no MCP overhead
|
||||
- **Built-in Core Tools** — bash (with interactive sudo password prompt), read, write, edit, grep, find, ls, subagent with no MCP overhead
|
||||
- **Smart @ Attachments** — Binary files auto-detected via MIME type, MCP resources via `@mcp:server:uri`
|
||||
- **MCP Integration** — Connect external MCP servers for expanded capabilities (tools, prompts, and resources)
|
||||
- **Extension System** — Write custom tools, commands, widgets, and UI modifications in Go
|
||||
|
||||
@@ -100,6 +100,19 @@ kit.HookPriorityLow = 100 // runs last
|
||||
|
||||
Lower values run first. First non-nil result wins.
|
||||
|
||||
## All event types
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `ToolCallEvent` | Tool call parsed and about to execute |
|
||||
| `ToolResultEvent` | Tool execution completed with result |
|
||||
| `ToolOutputEvent` | Streaming output chunk from tool (e.g., bash stdout/stderr) |
|
||||
| `MessageUpdateEvent` | Streaming text chunk from LLM |
|
||||
| `ResponseEvent` | Final response received |
|
||||
| `TurnStartEvent` | Agent turn started |
|
||||
| `TurnEndEvent` | Agent turn completed |
|
||||
| `PasswordPromptEvent` | Sudo command needs password (respond via `ResponseCh`) |
|
||||
|
||||
## Subagent event monitoring
|
||||
|
||||
Monitor real-time events from LLM-initiated subagents (when the model uses the `subagent` tool):
|
||||
|
||||
+178
-8
@@ -22,6 +22,20 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
Quiet: true,
|
||||
Debug: true,
|
||||
|
||||
// Generation parameters (override env/config/per-model defaults)
|
||||
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
|
||||
ThinkingLevel: "medium", // "off", "low", "medium", "high"
|
||||
Temperature: ptrFloat32(0.2), // pointer so explicit 0.0 != unset
|
||||
TopP: nil, // nil = provider/per-model default
|
||||
TopK: nil,
|
||||
FrequencyPenalty: nil,
|
||||
PresencePenalty: nil,
|
||||
|
||||
// Provider configuration
|
||||
ProviderAPIKey: "sk-...", // "" = use config / provider env var
|
||||
ProviderURL: "https://proxy.internal/v1", // "" = provider default endpoint
|
||||
TLSSkipVerify: false, // only effective when true
|
||||
|
||||
// Session
|
||||
SessionPath: "./session.jsonl",
|
||||
SessionDir: "/custom/sessions/",
|
||||
@@ -51,7 +65,11 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
// Session (advanced)
|
||||
SessionManager: myCustomSession, // custom SessionManager implementation
|
||||
|
||||
// MCP OAuth
|
||||
// MCP OAuth — both opt-in. Leave MCPAuthHandler nil to disable
|
||||
// OAuth entirely (remote MCP 401s bubble up as errors). CLI apps
|
||||
// pass kit.NewCLIMCPAuthHandler(); custom UX embedders implement
|
||||
// MCPAuthHandler or configure DefaultMCPAuthHandler + OnAuthURL.
|
||||
MCPAuthHandler: authHandler, // nil = OAuth disabled
|
||||
MCPTokenStoreFactory: func(serverURL string) (kit.MCPTokenStore, error) {
|
||||
return myStore(serverURL), nil
|
||||
},
|
||||
@@ -65,6 +83,8 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
|
||||
## Options fields
|
||||
|
||||
### Core
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `Model` | `string` | config default | Model string (provider/model format) |
|
||||
@@ -74,25 +94,175 @@ host, err := kit.New(ctx, &kit.Options{
|
||||
| `Streaming` | `bool` | `true` | Enable streaming output |
|
||||
| `Quiet` | `bool` | `false` | Suppress output |
|
||||
| `Debug` | `bool` | `false` | Enable debug logging |
|
||||
|
||||
### Generation parameters
|
||||
|
||||
These fields override the corresponding values from `.kit.yml` / `KIT_*`
|
||||
environment variables. Leaving a field at its zero/nil value lets the
|
||||
precedence chain resolve a value (`KIT_*` env → config file → per-model
|
||||
defaults from `modelSettings`/`customModels` → an 8192 SDK floor for
|
||||
`MaxTokens` (matching the CLI `--max-tokens` default) and provider-level
|
||||
defaults for samplers).
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `MaxTokens` | `int` | auto-resolved | Max output tokens per response. `0` = auto-resolve; non-zero suppresses automatic right-sizing (same semantics as `--max-tokens`). |
|
||||
| `ThinkingLevel` | `string` | auto-resolved | Reasoning effort: `"off"`, `"low"`, `"medium"`, `"high"` (some providers also accept `"minimal"`). `""` falls through to config/env/per-model/`"off"`. |
|
||||
| `Temperature` | `*float32` | — | Sampling randomness. Pointer type so explicit `0.0` is distinguishable from "unset". |
|
||||
| `TopP` | `*float32` | — | Nucleus sampling cutoff. `nil` leaves provider/per-model default. |
|
||||
| `TopK` | `*int32` | — | Top-K sampling limit. `nil` leaves provider/per-model default. |
|
||||
| `FrequencyPenalty` | `*float32` | — | OpenAI-family frequency penalty. `nil` leaves provider default. |
|
||||
| `PresencePenalty` | `*float32` | — | OpenAI-family presence penalty. `nil` leaves provider default. |
|
||||
|
||||
Pointer-typed samplers are populated via a tiny helper:
|
||||
|
||||
```go
|
||||
func ptrFloat32(v float32) *float32 { return &v }
|
||||
```
|
||||
|
||||
These fields eliminate the need for `viper.Set()` calls before `kit.New()`
|
||||
when embedding Kit as a library.
|
||||
|
||||
### Provider configuration
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `ProviderAPIKey` | `string` | — | API key used to authenticate with the provider. `""` falls back to config / provider-specific env var (e.g. `ANTHROPIC_API_KEY`). When set, overrides any pre-existing viper state. |
|
||||
| `ProviderURL` | `string` | — | Override the provider endpoint (e.g. LiteLLM, vLLM, Azure OpenAI, internal proxy). `""` = provider default. |
|
||||
| `TLSSkipVerify` | `bool` | `false` | Disable TLS certificate verification on the provider HTTP client. Only effective when `true`; to force-disable, use config file or env var instead. For self-signed dev certs only. |
|
||||
|
||||
### Session
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `SessionPath` | `string` | — | Open a specific session file |
|
||||
| `SessionDir` | `string` | — | Base directory for session discovery |
|
||||
| `Continue` | `bool` | `false` | Resume most recent session |
|
||||
| `NoSession` | `bool` | `false` | Ephemeral mode (no persistence) |
|
||||
| `SessionManager` | `SessionManager` | — | Custom session backend (advanced) |
|
||||
|
||||
### Tools & extensions
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `Tools` | `[]Tool` | — | Replace the entire default tool set |
|
||||
| `ExtraTools` | `[]Tool` | — | Additional tools alongside core/MCP/extension tools |
|
||||
| `DisableCoreTools` | `bool` | `false` | Use no core tools (0 tools, for chat-only) |
|
||||
| `SkipConfig` | `bool` | `false` | Skip .kit.yml file loading |
|
||||
| `AutoCompact` | `bool` | `false` | Auto-compact when near context limit |
|
||||
| `CompactionOptions` | `*CompactionOptions` | — | Configuration for auto-compaction |
|
||||
| `NoExtensions` | `bool` | `false` | Disable Yaegi extension loading |
|
||||
| `NoContextFiles` | `bool` | `false` | Disable automatic AGENTS.md loading |
|
||||
|
||||
### Skills & configuration
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `SkipConfig` | `bool` | `false` | Skip `.kit.yml` file loading (viper defaults + env vars still apply) |
|
||||
| `Skills` | `[]string` | — | Explicit skill files/dirs to load |
|
||||
| `SkillsDir` | `string` | — | Override default skills directory |
|
||||
| `NoSkills` | `bool` | `false` | Disable skill loading entirely |
|
||||
| `NoExtensions` | `bool` | `false` | Disable Yaegi extension loading |
|
||||
| `NoContextFiles` | `bool` | `false` | Disable automatic AGENTS.md loading |
|
||||
| `SessionManager` | `SessionManager` | — | Custom session backend (advanced) |
|
||||
| `MCPTokenStoreFactory` | `func` | — | Custom OAuth token storage for MCP servers |
|
||||
|
||||
### Compaction & MCP
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `AutoCompact` | `bool` | `false` | Auto-compact when near context limit |
|
||||
| `CompactionOptions` | `*CompactionOptions` | — | Configuration for auto-compaction |
|
||||
| `MCPAuthHandler` | `MCPAuthHandler` | — | OAuth handler for remote MCP servers. `nil` disables OAuth (servers returning 401 fail with the authorization-required error). See [MCP OAuth](#mcp-oauth-authorization) below. |
|
||||
| `MCPTokenStoreFactory` | `func` | — | Custom OAuth token storage for MCP servers (default: JSON file in `$XDG_CONFIG_HOME/.kit/mcp_tokens.json`). |
|
||||
| `InProcessMCPServers` | `map[string]*MCPServer` | — | In-process mcp-go servers (no subprocess) |
|
||||
|
||||
## MCP OAuth Authorization
|
||||
|
||||
When a remote MCP server (SSE or Streamable HTTP) requires OAuth, Kit runs
|
||||
the full authorization flow (dynamic client registration → PKCE → user
|
||||
consent → token exchange → token persistence) but delegates the **user-facing
|
||||
step** — displaying the authorization URL and receiving the callback — to
|
||||
an `MCPAuthHandler`.
|
||||
|
||||
The SDK is deliberately inert when `MCPAuthHandler` is `nil`: it does **not**
|
||||
auto-construct a default handler, bind a local TCP port, or open a browser.
|
||||
This keeps library, daemon, and web-app embedders free of surprise I/O.
|
||||
Consumers opt in by passing a handler explicitly.
|
||||
|
||||
| Building block | When to use |
|
||||
|---|---|
|
||||
| `MCPAuthHandler = nil` (default) | OAuth disabled. Remote MCP servers requiring auth fail with a clear error. Correct for libraries, daemons, and web apps. |
|
||||
| `kit.NewCLIMCPAuthHandler()` | CLI/TUI apps. Opens the system browser, prints status to stderr (or via `NotifyFunc`), runs a localhost callback server. Used by the `kit` binary. |
|
||||
| `kit.NewDefaultMCPAuthHandler()` + `OnAuthURL` | Custom UX. Use the SDK's port reservation and callback server; plug in your own presentation via the `OnAuthURL(serverName, authURL)` closure. |
|
||||
| Implement `kit.MCPAuthHandler` directly | Full control. No localhost binding — e.g. return the URL from an HTTP endpoint and have the consumer POST the callback URL back. |
|
||||
|
||||
**CLI-style embedder:**
|
||||
|
||||
```go
|
||||
authHandler, err := kit.NewCLIMCPAuthHandler()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer authHandler.Close() // release the reserved port
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
```
|
||||
|
||||
**Custom UX embedder (TUI modal, QR code, web redirect, etc.):**
|
||||
|
||||
```go
|
||||
authHandler, _ := kit.NewDefaultMCPAuthHandler()
|
||||
authHandler.OnAuthURL = func(serverName, authURL string) {
|
||||
// No browser or terminal assumptions — render however you like.
|
||||
myUI.ShowAuthPrompt(serverName, authURL)
|
||||
}
|
||||
defer authHandler.Close()
|
||||
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
MCPAuthHandler: authHandler,
|
||||
})
|
||||
```
|
||||
|
||||
**Fully custom handler (no local port binding at all):**
|
||||
|
||||
```go
|
||||
type WebAuthHandler struct {
|
||||
redirectURI string
|
||||
callbacks chan string
|
||||
}
|
||||
|
||||
func (h *WebAuthHandler) RedirectURI() string { return h.redirectURI }
|
||||
|
||||
func (h *WebAuthHandler) HandleAuth(ctx context.Context, serverName, authURL string) (string, error) {
|
||||
// Push the URL to the user's existing browser session via your web app,
|
||||
// then block on the callback that your HTTP handler pushes onto the channel.
|
||||
h.pushToUserSession(serverName, authURL)
|
||||
select {
|
||||
case callbackURL := <-h.callbacks:
|
||||
return callbackURL, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: warning
|
||||
`DefaultMCPAuthHandler` with no `OnAuthURL` set will silently drop the
|
||||
authorization URL and hang until the 2-minute callback timeout fires. Always
|
||||
set `OnAuthURL`, or use a higher-level wrapper like `CLIMCPAuthHandler`.
|
||||
:::
|
||||
|
||||
## Precedence
|
||||
|
||||
For any given generation or provider field, the effective value is resolved
|
||||
in this order (highest priority first):
|
||||
|
||||
1. `Options.X` (SDK caller)
|
||||
2. `KIT_X` environment variable
|
||||
3. `.kit.yml` (project-local then `~/.kit.yml`)
|
||||
4. Per-model defaults (`modelSettings[provider/model]` or `customModels[...].params`)
|
||||
5. Provider-level defaults (e.g. Anthropic's own temperature default)
|
||||
6. SDK last-resort floor (currently: `MaxTokens = 8192`, matching the CLI `--max-tokens` default)
|
||||
|
||||
Sampling params that remain `nil` after the SDK resolution step are left out
|
||||
of the provider call entirely, so the LLM library applies its own default.
|
||||
|
||||
## Tool configuration
|
||||
|
||||
**`Tools`** replaces ALL default tools (core + MCP + extension). **`ExtraTools`** adds tools alongside the defaults. Use `Tools` to restrict capabilities; use `ExtraTools` to extend them.
|
||||
|
||||
@@ -106,6 +106,27 @@ For advanced use, return a `kit.ToolOutput` struct directly with `Data`, `MediaT
|
||||
|
||||
Use `kit.NewParallelTool` for tools that are safe to run concurrently. Use `kit.ToolCallIDFromContext(ctx)` to retrieve the LLM-assigned call ID for logging or tracing.
|
||||
|
||||
## Generation & provider overrides
|
||||
|
||||
SDK consumers can configure generation parameters and provider endpoints
|
||||
entirely in-code via `Options`, without touching `.kit.yml` or `viper.Set()`:
|
||||
|
||||
```go
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
Model: "anthropic/claude-sonnet-4-5-20250929",
|
||||
MaxTokens: 16384, // 0 = auto-resolve (env → config → per-model → floor)
|
||||
ThinkingLevel: "high", // "off" | "low" | "medium" | "high"
|
||||
Temperature: ptrFloat32(0.2), // nil = provider/per-model default
|
||||
ProviderAPIKey: os.Getenv("MY_SECRET"), // overrides pre-existing viper state
|
||||
ProviderURL: "https://proxy.internal/v1",
|
||||
})
|
||||
|
||||
func ptrFloat32(v float32) *float32 { return &v }
|
||||
```
|
||||
|
||||
See [Options](/sdk/options#generation-parameters) for the full field reference,
|
||||
including `TopP`, `TopK`, `FrequencyPenalty`, `PresencePenalty`, and `TLSSkipVerify`.
|
||||
|
||||
## Event system
|
||||
|
||||
Subscribe to events for monitoring:
|
||||
|
||||
Reference in New Issue
Block a user