feat(FN-882): add loop detection recovery with compact-and-resume

- Add ContextLimitDetector to detect agent loops via repeated tool call patterns
- Implement compact-and-resume strategy: summarize conversation and restart agent from current step
- Add loop recovery to StuckTaskDetector with configurable attempt tracking and retry limits
- Extend executor with automatic loop recovery on context limit detection
- Add loop recovery support to pi executor with same compact-and-resume pattern
- Add comprehensive tests for context-limit-detector, stuck-task-detector loop detection, executor, and pi recovery
- Add changeset for patch bump to @gsxdsm/fusion
- Update README with loop detection and recovery documentation
This commit is contained in:
gsxdsm
2026-04-04 19:47:48 -07:00
parent c0b1c2ae30
commit 4c2be10d73
12 changed files with 846 additions and 6 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { isContextLimitError } from "./context-limit-detector.js";
describe("isContextLimitError", () => {
// ── Positive matches: known provider patterns ──────────────────────
it("matches Anthropic 'prompt is too long' error", () => {
expect(isContextLimitError("prompt is too long: 210000 tokens > 200000 maximum")).toBe(true);
});
it("matches OpenAI 'exceeds the context window' error", () => {
expect(isContextLimitError("This model's maximum context length is 128000 tokens. Your input exceeds the context window.")).toBe(true);
});
it("matches Google Gemini 'input token count exceeds' error", () => {
expect(isContextLimitError("input token count exceeds the maximum limit of 1000000")).toBe(true);
});
it("matches xAI 'maximum prompt length' error", () => {
expect(isContextLimitError("maximum prompt length is 131072 but request contains 150000")).toBe(true);
});
it("matches Groq 'reduce the length of the messages' error", () => {
expect(isContextLimitError("Please reduce the length of the messages")).toBe(true);
});
it("matches Mistral context length error", () => {
expect(isContextLimitError("Prompt contains 35000 tokens ... too large for model with 32000 maximum context length")).toBe(true);
});
it("matches OpenRouter context length error", () => {
expect(isContextLimitError("maximum context length is 128000 tokens")).toBe(true);
});
it("matches llama.cpp 'exceeds the available context size' error", () => {
expect(isContextLimitError("attempt to access position 8193 exceeds the available context size")).toBe(true);
});
it("matches LM Studio 'greater than the context length' error", () => {
expect(isContextLimitError("request has 9000 tokens which is greater than the context length of 8192")).toBe(true);
});
it("matches Kimi 'exceeded model token limit' error", () => {
expect(isContextLimitError("exceeded model token limit: 131072 (requested: 150000)")).toBe(true);
});
it("matches generic 'context length exceeded'", () => {
expect(isContextLimitError("context length exceeded")).toBe(true);
});
it("matches generic 'context window exceeded'", () => {
expect(isContextLimitError("context window exceeded")).toBe(true);
});
it("matches generic 'context size exceeded'", () => {
expect(isContextLimitError("context size exceeded")).toBe(true);
});
it("matches 'too many tokens'", () => {
expect(isContextLimitError("too many tokens in request")).toBe(true);
});
it("matches Anthropic 'would exceed' variant", () => {
expect(isContextLimitError("messages with that many tokens would exceed the limit")).toBe(true);
});
it("matches 'token limit ... context' pattern", () => {
expect(isContextLimitError("token limit reached for context window")).toBe(true);
});
// ── Negative matches: must NOT trigger ─────────────────────────────
it("returns false for empty string", () => {
expect(isContextLimitError("")).toBe(false);
});
it("returns false for undefined-ish empty input", () => {
expect(isContextLimitError("")).toBe(false);
});
it("returns false for generic 'Aborted' error", () => {
expect(isContextLimitError("Aborted")).toBe(false);
});
it("returns false for rate limit error", () => {
expect(isContextLimitError("429 Too Many Requests")).toBe(false);
expect(isContextLimitError("rate_limit_error: Rate limit exceeded")).toBe(false);
});
it("returns false for generic 'limit exceeded' without context keywords", () => {
expect(isContextLimitError("limit exceeded")).toBe(false);
expect(isContextLimitError("quota exceeded")).toBe(false);
});
it("returns false for server error", () => {
expect(isContextLimitError("500 Internal Server Error")).toBe(false);
});
it("returns false for connection error", () => {
expect(isContextLimitError("ECONNREFUSED")).toBe(false);
expect(isContextLimitError("connection refused")).toBe(false);
});
it("returns false for usage/billing error", () => {
expect(isContextLimitError("billing limit reached")).toBe(false);
expect(isContextLimitError("usage cap exceeded")).toBe(false);
});
it("returns false for transient network error", () => {
expect(isContextLimitError("fetch failed")).toBe(false);
expect(isContextLimitError("ETIMEDOUT")).toBe(false);
});
it("returns false for overloaded error (not context)", () => {
expect(isContextLimitError("overloaded_error: Overloaded")).toBe(false);
});
});