diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 9bb4ec0..95a5a0a 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -38,6 +38,7 @@ odek ships with built-in **model profiles** that automatically apply sensible de | `deepseek-chat` | DeepSeek (legacy) | (provider default) | 120s | 128K | General | | `deepseek-v4-flash` | DeepSeek v4 Flash | — (faster/cheaper) | 90s | 128K | Quick tasks, coding | | `deepseek-v4-pro` | DeepSeek v4 Pro | `enabled` | 180s | **1M** | Deep reasoning | +| `kimi-…`, `k3…` (e.g. `kimi-for-coding`, `k3-256k`) | Kimi | (provider default) | 300s | 256K | Agentic coding | | *(any other)* | Generic | (profile default) | 120s | (no limit) | Custom models | ### How profiles work diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 45e4252..975f78e 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -269,6 +269,11 @@ type Engine struct { // groups (Config.Compaction). compactDigest holds the current summary. compaction bool compactDigest string + + // sideCallTimeout bounds the compaction and progress-summary side calls. + // Zero means use the default (30s). Callers scale it off the resolved + // client timeout so slow providers don't silently lose the digest. + sideCallTimeout time.Duration } // New creates a new loop Engine. @@ -371,6 +376,22 @@ func (e *Engine) SetDangerousConfig(cfg *danger.DangerousConfig) { e.dangerousCf // so it is wrapped with the engine's untrusted-content wrapper when set. func (e *Engine) SetCompaction(enabled bool) { e.compaction = enabled } +// SetSideCallTimeout sets the bound for the compaction digest and +// progress-summary side calls. 0 or negative restores the default (30s). +func (e *Engine) SetSideCallTimeout(d time.Duration) { e.sideCallTimeout = d } + +// SideCallTimeout returns the effective bound for the compaction digest and +// progress-summary side calls (default 30s). +func (e *Engine) SideCallTimeout() time.Duration { return e.sideTimeout() } + +// sideTimeout returns the effective side-call timeout. +func (e *Engine) sideTimeout() time.Duration { + if e.sideCallTimeout > 0 { + return e.sideCallTimeout + } + return 30 * time.Second +} + // ── Token Estimation ───────────────────────────────────────────────── // // Zero-dependency heuristic: 1 token ≈ 4 chars for English text. @@ -931,9 +952,9 @@ func (e *Engine) refreshDigest(ctx context.Context, messages []llm.Message, drop } // summarizeDropped builds the summarizer input from the dropped messages and -// the previous digest, then calls the LLM with a bounded timeout. Returns an -// empty string on any failure — compaction is best-effort and must never -// break the agent loop. +// the previous digest, then calls the LLM with a bounded timeout (sideTimeout). +// Returns an empty string on any failure — compaction is best-effort and must +// never break the agent loop. func (e *Engine) summarizeDropped(ctx context.Context, dropped []llm.Message) string { if e.client == nil { return "" @@ -968,7 +989,7 @@ func (e *Engine) summarizeDropped(ctx context.Context, dropped []llm.Message) st return "" } - callCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + callCtx, cancel := context.WithTimeout(ctx, e.sideTimeout()) defer cancel() res, err := e.client.Call(callCtx, []llm.Message{ {Role: "system", Content: compactionSystemPrompt}, @@ -981,8 +1002,9 @@ func (e *Engine) summarizeDropped(ctx context.Context, dropped []llm.Message) st } // summarizeProgress renders the tail of the conversation into a bounded -// summarizer input and makes one final tool-less LLM call (30s bound, same -// pattern as the compaction side-call) asking for a progress summary. +// summarizer input and makes one final tool-less LLM call (bounded by +// sideTimeout, same pattern as the compaction side-call) asking for a +// progress summary. // Returns an empty string on any failure — including a non-compliant // response that still requests tool calls — so the caller can fall back to // the plain budget-exhaustion error. @@ -1016,7 +1038,7 @@ func (e *Engine) summarizeProgress(ctx context.Context, messages []llm.Message) return "" } - callCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + callCtx, cancel := context.WithTimeout(ctx, e.sideTimeout()) defer cancel() res, err := e.client.Call(callCtx, []llm.Message{ {Role: "system", Content: budgetSummarySystemPrompt}, diff --git a/internal/loop/loop_trim_test.go b/internal/loop/loop_trim_test.go index bb5b0fd..d3b3762 100644 --- a/internal/loop/loop_trim_test.go +++ b/internal/loop/loop_trim_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/BackendStack21/odek/internal/llm" "github.com/BackendStack21/odek/internal/tool" @@ -616,6 +617,21 @@ func TestTrimToSurvival_IncludesPrecedingSystemMessages(t *testing.T) { // ── Coverage: summarizeDropped branches ──────────────────────────────── +func TestSideTimeout_DefaultAndOverride(t *testing.T) { + engine := &Engine{} + if got := engine.sideTimeout(); got != 30*time.Second { + t.Errorf("default sideTimeout = %v, want 30s", got) + } + engine.SetSideCallTimeout(120 * time.Second) + if got := engine.sideTimeout(); got != 120*time.Second { + t.Errorf("sideTimeout = %v, want 120s", got) + } + engine.SetSideCallTimeout(0) + if got := engine.sideTimeout(); got != 30*time.Second { + t.Errorf("zero SetSideCallTimeout must restore 30s default, got %v", got) + } +} + func TestSummarizeDropped_NilClient(t *testing.T) { engine := &Engine{} if got := engine.summarizeDropped(context.Background(), []llm.Message{{Role: "assistant", Content: "x"}}); got != "" { diff --git a/odek.go b/odek.go index 2386ce6..1fcab9a 100644 --- a/odek.go +++ b/odek.go @@ -284,6 +284,24 @@ var KnownProfiles = []struct { Prefix string Profile ModelProfile }{ + { + Prefix: "kimi-", + Profile: ModelProfile{ + Label: "Kimi", + Timeout: 300, // reasoning models can be slow to first byte + MaxContext: 262_144, // 256K safe default; /models discovery takes priority + }, + }, + { + // Kimi Code also ships models under the "k3" family name (k3, k3-256k), + // which the "kimi-" prefix does not match. + Prefix: "k3", + Profile: ModelProfile{ + Label: "Kimi", + Timeout: 300, + MaxContext: 262_144, + }, + }, { Prefix: "deepseek-v4-pro", Profile: ModelProfile{ @@ -589,6 +607,14 @@ func New(cfg Config) (*Agent, error) { engine := loop.New(client, registry, cfg.MaxIterations, cfg.SystemMessage, cfg.Renderer, maxContext) engine.PromptCaching = cfg.PromptCaching engine.SetCompaction(cfg.Compaction) + // Side calls (compaction digest, progress summary) use the same client and + // model, so scale their bound off the resolved request timeout — a slow + // provider would otherwise blow the 30s default and silently drop the digest. + sideTimeout := time.Duration(timeout) * time.Second + if sideTimeout > 120*time.Second { + sideTimeout = 120 * time.Second + } + engine.SetSideCallTimeout(sideTimeout) engine.SetUntrustedWrapper(cfg.UntrustedWrapper) if cfg.MaxToolParallel > 0 { engine.SetMaxToolParallel(cfg.MaxToolParallel) diff --git a/odek_test.go b/odek_test.go index 1def162..12e63ad 100644 --- a/odek_test.go +++ b/odek_test.go @@ -528,6 +528,33 @@ func TestLookupProfile_NoMatch(t *testing.T) { } } +func TestLookupProfile_KimiMatch(t *testing.T) { + // Any kimi-* model (e.g. kimi-for-coding) matches the "kimi-" profile: + // a longer timeout for slow reasoning responses, 256K context fallback. + p := LookupProfile("kimi-for-coding") + if p == nil { + t.Fatal("LookupProfile(\"kimi-for-coding\") returned nil") + } + if p.Timeout != 300 { + t.Errorf("Timeout = %d, want 300", p.Timeout) + } + if p.MaxContext != 262_144 { + t.Errorf("MaxContext = %d, want 262144", p.MaxContext) + } + + // The k3 family (k3, k3-256k) is the same Kimi Code line under a + // different prefix — it must get the same profile. + for _, model := range []string{"k3", "k3-256k"} { + p := LookupProfile(model) + if p == nil { + t.Fatalf("LookupProfile(%q) returned nil", model) + } + if p.Timeout != 300 || p.MaxContext != 262_144 { + t.Errorf("LookupProfile(%q) = %+v, want Timeout=300 MaxContext=262144", model, p) + } + } +} + func TestProfileLabel_Known(t *testing.T) { if label := ProfileLabel("deepseek-v4-pro"); label != "DeepSeek v4 Pro" { t.Errorf("ProfileLabel = %q, want %q", label, "DeepSeek v4 Pro") @@ -600,6 +627,30 @@ func TestNew_ProfileTimeout_Pro(t *testing.T) { } } +func TestNew_SideCallTimeoutScaling(t *testing.T) { + // The compaction/progress-summary side-call bound scales off the resolved + // client timeout, capped at 120s. + cases := []struct { + model string + want time.Duration + }{ + {"kimi-for-coding", 120 * time.Second}, // 300s client timeout → capped at 120s + {"k3-256k", 120 * time.Second}, // same profile via the k3 prefix + {"deepseek-v4-pro", 120 * time.Second}, // 180s → capped at 120s + {"deepseek-v4-flash", 90 * time.Second}, + {"gpt-4o", 120 * time.Second}, // unknown model → 120s default + } + for _, tc := range cases { + agent, err := New(Config{APIKey: "sk-test", Model: tc.model}) + if err != nil { + t.Fatalf("New(%q): %v", tc.model, err) + } + if got := agent.engine.SideCallTimeout(); got != tc.want { + t.Errorf("New(%q) side-call timeout = %v, want %v", tc.model, got, tc.want) + } + } +} + func TestNew_DefaultModelNoProfile(t *testing.T) { // deepseek-chat is not in KnownProfiles — no profile defaults applied cfg := Config{