Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions internal/loop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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},
Expand All @@ -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.
Expand Down Expand Up @@ -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},
Expand Down
16 changes: 16 additions & 0 deletions internal/loop/loop_trim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/BackendStack21/odek/internal/llm"
"github.com/BackendStack21/odek/internal/tool"
Expand Down Expand Up @@ -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 != "" {
Expand Down
26 changes: 26 additions & 0 deletions odek.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions odek_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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{
Expand Down
Loading