Skip to content

Commit fff12a5

Browse files
committed
v0.55.0 — Context-limit protection + Telegram message-length fix
Two fixes for session stability: 1. Context-limit-exceeded error recovery (loop.go): - Added isContextLengthError() — detects context-length errors from DeepSeek, OpenAI, and Anthropic providers - Added trimToSurvival() — nuclear-option trimming that drops all but system prompt + last 2 turn groups + last user message - Modified runLoop error handling: on context-length error, auto-trims and retries once instead of failing the session - 6 new tests: error detection (positive + negative), survival trimming (minimal, multi-turn, no-system) 2. Telegram verbose progress truncation (telegram.go): - Added truncateToolArgs() — truncates tool call JSON arguments to 2000 chars for verbose progress messages - Prevents Telegram "message is too long" (400) error for large write_file/batch_write content payloads - 3 new tests: short passthrough, long truncation, exact boundary
1 parent 89fbcc9 commit fff12a5

4 files changed

Lines changed: 313 additions & 1 deletion

File tree

cmd/odek/telegram.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1306,7 +1306,7 @@ func handleChatMessage(
13061306

13071307
if toolProgress == "verbose" {
13081308
recordToolStart()
1309-
line := fmt.Sprintf("%s `%s` %s", render.ToolEmoji(name), name, data)
1309+
line := fmt.Sprintf("%s `%s` %s", render.ToolEmoji(name), name, truncateToolArgs(data, 2000))
13101310
line = telegram.EscapeMarkdown(line)
13111311
bot.SendMessage(chatID, line,
13121312
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2})
@@ -1717,6 +1717,17 @@ func sendAsync(bot *telegram.Bot, chatID int64, text string, opts *telegram.Send
17171717
}()
17181718
}
17191719

1720+
// truncateToolArgs truncates tool call arguments to at most maxLen characters
1721+
// for Telegram progress display. This prevents the "message is too long" error
1722+
// (Telegram's 4096 character limit) when sending verbose progress messages for
1723+
// calls like write_file that include large content payloads.
1724+
func truncateToolArgs(data string, maxLen int) string {
1725+
if len(data) <= maxLen {
1726+
return data
1727+
}
1728+
return data[:maxLen] + fmt.Sprintf("… [%d more bytes]", len(data)-maxLen)
1729+
}
1730+
17201731
// ── Singleton Lock ─────────────────────────────────────────────────────
17211732
//
17221733
// Prevents two bot instances from polling Telegram simultaneously (which

cmd/odek/telegram_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,3 +731,32 @@ func TestToolLatencyTracking(t *testing.T) {
731731
t.Errorf("expected empty after draining queue, got %q", lat)
732732
}
733733
}
734+
735+
// ── truncateToolArgs tests ────────────────────────────────────────────
736+
737+
func TestTruncateToolArgs_Short(t *testing.T) {
738+
data := `{"path": "test.go"}`
739+
got := truncateToolArgs(data, 2000)
740+
if got != data {
741+
t.Errorf("short data should not be truncated: got %q", got)
742+
}
743+
}
744+
745+
func TestTruncateToolArgs_Long(t *testing.T) {
746+
data := `{"content": "` + strings.Repeat("A", 5000) + `"}`
747+
got := truncateToolArgs(data, 100)
748+
if len(got) >= len(data) {
749+
t.Error("long data should be truncated")
750+
}
751+
if !strings.Contains(got, "more bytes") {
752+
t.Error("truncated data should include byte count")
753+
}
754+
}
755+
756+
func TestTruncateToolArgs_ExactBoundary(t *testing.T) {
757+
data := strings.Repeat("x", 100)
758+
got := truncateToolArgs(data, 100)
759+
if got != data {
760+
t.Errorf("data at exact maxLen should not be truncated: got len=%d", len(got))
761+
}
762+
}

internal/loop/loop.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,99 @@ func (e *Engine) trimContext(messages []llm.Message, toolDefs []llm.ToolDef) []l
372372
return messages
373373
}
374374

375+
// isContextLengthError returns true for API errors that indicate the
376+
// input exceeded the model's context window. These errors are retryable
377+
// with aggressive trimming rather than killing the session.
378+
func isContextLengthError(err error) bool {
379+
if err == nil {
380+
return false
381+
}
382+
msg := err.Error()
383+
// Common error patterns across providers:
384+
// DeepSeek: "context_length_exceeded", "maximum context length"
385+
// OpenAI: "maximum context length", "token limit"
386+
// Anthropic: "input is too long", "context window"
387+
return strings.Contains(msg, "context_length_exceeded") ||
388+
strings.Contains(msg, "maximum context length") ||
389+
strings.Contains(msg, "context length") ||
390+
strings.Contains(msg, "token limit") ||
391+
strings.Contains(msg, "context window") ||
392+
strings.Contains(msg, "max_input_tokens") ||
393+
strings.Contains(msg, "input length") ||
394+
strings.Contains(msg, "too many tokens") ||
395+
strings.Contains(msg, "input is too long") ||
396+
strings.Contains(msg, "reduce the length")
397+
}
398+
399+
// trimToSurvival drops all but the system prompt, first user message,
400+
// and the most recent 2 complete turn groups. This is the nuclear option
401+
// used when the API rejects the request as context-length-exceeded.
402+
// Unlike trimContext which gives up when it can't stay under budget,
403+
// trimToSurvival always produces a drastically reduced message list
404+
// that nearly every model can handle.
405+
func trimToSurvival(msgs []llm.Message) []llm.Message {
406+
if len(msgs) <= 3 {
407+
return msgs // already minimal enough
408+
}
409+
start := 0
410+
if msgs[0].Role == "system" {
411+
start = 1 // keep system
412+
}
413+
// Last user message (the current task/input) — always keep it.
414+
var lastUser llm.Message
415+
lastUserIdx := -1
416+
for i := len(msgs) - 1; i >= 0; i-- {
417+
if msgs[i].Role == "user" {
418+
lastUser = msgs[i]
419+
lastUserIdx = i
420+
break
421+
}
422+
}
423+
424+
// Collect the last 2 complete assistant→tool groups before the user msg.
425+
var groups []llm.Message
426+
seen := 0
427+
for i := lastUserIdx - 1; i > start && seen < 2; i-- {
428+
if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 {
429+
// This assistant message has tool calls — collect it and
430+
// its following tool results.
431+
groupEnd := i + 1
432+
for groupEnd < len(msgs) && msgs[groupEnd].Role == "tool" {
433+
groupEnd++
434+
}
435+
groups = append(groups, msgs[i:groupEnd]...)
436+
// Also include any preceding system messages (corrections, warnings).
437+
preStart := i - 1
438+
for preStart > start && msgs[preStart].Role == "system" {
439+
preStart--
440+
}
441+
for k := preStart + 1; k < i; k++ {
442+
groups = append(groups, msgs[k])
443+
}
444+
seen++
445+
}
446+
}
447+
448+
// Build survival set: system + task + recent groups + last user
449+
survival := make([]llm.Message, 0, start+1+len(groups)+1)
450+
if start > 0 {
451+
survival = append(survival, msgs[0]) // system message
452+
}
453+
// Add a context-warning system message
454+
warning := "[Context trimmed to survive: the conversation history exceeded the model's context window. Earlier turns have been dropped. If you need information from earlier in the conversation, the agent may ask for a summary.]"
455+
survival = append(survival, llm.Message{Role: "system", Content: warning})
456+
457+
// Add the recent groups (in chronological order, not reversed)
458+
for i := len(groups) - 1; i >= 0; i-- {
459+
survival = append(survival, groups[i])
460+
}
461+
462+
// Add the last user message
463+
survival = append(survival, lastUser)
464+
465+
return survival
466+
}
467+
375468
// ── Loop ──────────────────────────────────────────────────────────────
376469

377470
// Run executes the loop for a given task and returns the final response.
@@ -562,6 +655,26 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
562655
result, err := e.client.Call(ctx, callMsgs, systemBlocks, tools)
563656
latency := time.Since(start)
564657
if err != nil {
658+
// Context-length-exceeded errors: don't die — try aggressive
659+
// trimming and retry once. The trimContext at the top of the
660+
// loop may have been too conservative (75% budget) or the
661+
// provider's reported context window may be smaller than
662+
// the actual model limit.
663+
if isContextLengthError(err) {
664+
trimmed := trimToSurvival(messages)
665+
if len(trimmed) < len(messages) {
666+
messages = trimmed
667+
// Reset memory index — trimToSurvival drops it.
668+
e.memMsgIdx = -1
669+
// Inject survival warning as the final message
670+
// so the agent knows context was lost.
671+
messages = append(messages, llm.Message{
672+
Role: "system",
673+
Content: "[Context survival mode: the conversation was aggressively reduced to fit the model's context window. Continue from where you left off using the most recent context available.]",
674+
})
675+
continue // retry this iteration
676+
}
677+
}
565678
return "", messages, fmt.Errorf("iteration %d: %w", i, err)
566679
}
567680

internal/loop/loop_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,3 +1953,162 @@ func TestToolPanic_DoesNotKillAgent(t *testing.T) {
19531953
t.Errorf("agent result = %q, want 'Agent survived'", result)
19541954
}
19551955
}
1956+
1957+
// ── Context Length Error Detection ────────────────────────────────────
1958+
1959+
func TestIsContextLengthError_Nil(t *testing.T) {
1960+
if isContextLengthError(nil) {
1961+
t.Error("nil should not be a context length error")
1962+
}
1963+
}
1964+
1965+
func TestIsContextLengthError_DeepSeek(t *testing.T) {
1966+
err := fmt.Errorf("llm: 400 Bad Request (status 400): context_length_exceeded")
1967+
if !isContextLengthError(err) {
1968+
t.Error("DeepSeek context_length_exceeded should be detected")
1969+
}
1970+
}
1971+
1972+
func TestIsContextLengthError_OpenAI(t *testing.T) {
1973+
err := fmt.Errorf("llm: 400 Bad Request (status 400): maximum context length is 128000 tokens")
1974+
if !isContextLengthError(err) {
1975+
t.Error("OpenAI 'maximum context length' should be detected")
1976+
}
1977+
}
1978+
1979+
func TestIsContextLengthError_Anthropic(t *testing.T) {
1980+
err := fmt.Errorf("llm: 400 Bad Request: input is too long: token count 150000 exceeds max context window 200000")
1981+
if !isContextLengthError(err) {
1982+
t.Error("Anthropic 'input is too long' should be detected")
1983+
}
1984+
}
1985+
1986+
func TestIsContextLengthError_Negative(t *testing.T) {
1987+
errors := []error{
1988+
fmt.Errorf("llm: 401 Unauthorized (status 401)"),
1989+
fmt.Errorf("llm: connection refused"),
1990+
fmt.Errorf("llm: 429 Too Many Requests"),
1991+
fmt.Errorf("llm: timeout while awaiting headers"),
1992+
fmt.Errorf("llm: invalid API key"),
1993+
fmt.Errorf("internal server error"),
1994+
}
1995+
for _, err := range errors {
1996+
if isContextLengthError(err) {
1997+
t.Errorf("should not detect context length for: %v", err)
1998+
}
1999+
}
2000+
}
2001+
2002+
// ── trimToSurvival ────────────────────────────────────────────────────
2003+
2004+
func TestTrimToSurvival_AlreadyMinimal(t *testing.T) {
2005+
msgs := []llm.Message{
2006+
{Role: "system", Content: "you are a helpful agent"},
2007+
{Role: "user", Content: "do something"},
2008+
}
2009+
got := trimToSurvival(msgs)
2010+
if len(got) != 2 { // Already minimal — no warning needed
2011+
t.Errorf("expected 2 messages for minimal input, got %d", len(got))
2012+
}
2013+
if got[0].Content != msgs[0].Content {
2014+
t.Errorf("system message preserved: got %q, want %q", got[0].Content, msgs[0].Content)
2015+
}
2016+
if got[1].Content != msgs[1].Content {
2017+
t.Errorf("user message preserved: got %q, want %q", got[1].Content, msgs[1].Content)
2018+
}
2019+
}
2020+
2021+
func TestTrimToSurvival_DropsOldTurns(t *testing.T) {
2022+
msgs := []llm.Message{
2023+
{Role: "system", Content: "system prompt"},
2024+
{Role: "user", Content: "original task"},
2025+
// Turn 1
2026+
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{{ID: "c1", Function: struct {
2027+
Name string `json:"name"`
2028+
Arguments string `json:"arguments"`
2029+
}{Name: "read_file", Arguments: `{"path":"a.go"}`}}}},
2030+
{Role: "tool", Content: "result 1", Name: "read_file", ToolCallID: "c1"},
2031+
// Turn 2
2032+
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{{ID: "c2", Function: struct {
2033+
Name string `json:"name"`
2034+
Arguments string `json:"arguments"`
2035+
}{Name: "write_file", Arguments: `{"path":"b.go"}`}}}},
2036+
{Role: "tool", Content: "result 2", Name: "write_file", ToolCallID: "c2"},
2037+
// Turn 3 (most recently completed)
2038+
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{{ID: "c3", Function: struct {
2039+
Name string `json:"name"`
2040+
Arguments string `json:"arguments"`
2041+
}{Name: "search_files", Arguments: `{"pattern":"*.go"}`}}}},
2042+
{Role: "tool", Content: "result 3", Name: "search_files", ToolCallID: "c3"},
2043+
// Current user input
2044+
{Role: "user", Content: "continue working"},
2045+
}
2046+
got := trimToSurvival(msgs)
2047+
// Should have: system + warning + last 2 turns + last user
2048+
// Turn 1 should be dropped (only keep 2 most recent turns)
2049+
if len(got) < 5 {
2050+
t.Fatalf("expected at least 5 messages, got %d: %v", len(got), got)
2051+
}
2052+
// System preserved
2053+
if got[0].Content != "system prompt" {
2054+
t.Errorf("expected system prompt, got %q", got[0].Content)
2055+
}
2056+
// Warning injected
2057+
if !strings.Contains(got[1].Content, "Context trimmed") {
2058+
t.Errorf("expected context trim warning at index 1, got %q", got[1].Content)
2059+
}
2060+
// Last user message preserved as final message
2061+
last := got[len(got)-1]
2062+
if last.Role != "user" || last.Content != "continue working" {
2063+
t.Errorf("expected last user message preserved, got role=%q content=%q", last.Role, last.Content)
2064+
}
2065+
// Turn 1 (read_file) should be dropped — only last 2 turns survive
2066+
for _, m := range got {
2067+
if m.ToolCallID == "c1" {
2068+
t.Error("Turn 1 should have been dropped")
2069+
}
2070+
}
2071+
// Turn 2 and 3 should survive
2072+
foundTurn2 := false
2073+
foundTurn3 := false
2074+
for _, m := range got {
2075+
if m.ToolCallID == "c2" {
2076+
foundTurn2 = true
2077+
}
2078+
if m.ToolCallID == "c3" {
2079+
foundTurn3 = true
2080+
}
2081+
}
2082+
if !foundTurn2 {
2083+
t.Error("Turn 2 (recent) should survive")
2084+
}
2085+
if !foundTurn3 {
2086+
t.Error("Turn 3 (most recent) should survive")
2087+
}
2088+
}
2089+
2090+
func TestTrimToSurvival_NoSystem(t *testing.T) {
2091+
// Without system message, trimToSurvival still works
2092+
msgs := []llm.Message{
2093+
{Role: "user", Content: "task"},
2094+
{Role: "assistant", Content: "", ToolCalls: []llm.ToolCall{{ID: "c1", Function: struct {
2095+
Name string `json:"name"`
2096+
Arguments string `json:"arguments"`
2097+
}{Name: "echo", Arguments: `{}`}}}},
2098+
{Role: "tool", Content: "result", Name: "echo", ToolCallID: "c1"},
2099+
{Role: "user", Content: "continue"},
2100+
}
2101+
got := trimToSurvival(msgs)
2102+
if len(got) < 2 {
2103+
t.Fatalf("expected at least 2 messages, got %d", len(got))
2104+
}
2105+
// Warning message should be first
2106+
if !strings.Contains(got[0].Content, "Context trimmed") {
2107+
t.Errorf("expected warning at index 0, got %q", got[0].Content)
2108+
}
2109+
// Last user message preserved
2110+
last := got[len(got)-1]
2111+
if last.Content != "continue" {
2112+
t.Errorf("expected last user message, got %q", last.Content)
2113+
}
2114+
}

0 commit comments

Comments
 (0)