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

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { describeModel } from "./pi.js";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS } from "./pi.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
describe("describeModel", () => {
@@ -35,3 +35,83 @@ describe("describeModel", () => {
expect(describeModel(fakeSession)).toBe("openai/gpt-4o");
});
});
describe("COMPACTION_FALLBACK_INSTRUCTIONS", () => {
it("is a non-empty string", () => {
expect(COMPACTION_FALLBACK_INSTRUCTIONS).toBeTruthy();
expect(typeof COMPACTION_FALLBACK_INSTRUCTIONS).toBe("string");
expect(COMPACTION_FALLBACK_INSTRUCTIONS.length).toBeGreaterThan(0);
});
it("mentions summarizing completed steps", () => {
expect(COMPACTION_FALLBACK_INSTRUCTIONS).toContain("completed steps");
});
});
describe("compactSessionContext", () => {
it("returns null when session does not have compact method", async () => {
const session = {} as AgentSession;
const result = await compactSessionContext(session);
expect(result).toBeNull();
});
it("calls session.compact with default instructions when no custom instructions provided", async () => {
const compact = async (instructions: string) => ({
summary: "Compacted",
tokensBefore: 100000,
});
const session = { compact } as unknown as AgentSession;
const result = await compactSessionContext(session);
expect(result).toEqual({
summary: "Compacted",
tokensBefore: 100000,
});
});
it("calls session.compact with custom instructions when provided", async () => {
let capturedInstructions: string | undefined;
const compact = async (instructions: string) => {
capturedInstructions = instructions;
return { summary: "Custom", tokensBefore: 50000 };
};
const session = { compact } as unknown as AgentSession;
const result = await compactSessionContext(session, "Focus on step 3");
expect(capturedInstructions).toBe("Focus on step 3");
expect(result).toEqual({
summary: "Custom",
tokensBefore: 50000,
});
});
it("returns null when session.compact throws", async () => {
const compact = async () => { throw new Error("compaction failed"); };
const session = { compact } as unknown as AgentSession;
const result = await compactSessionContext(session);
expect(result).toBeNull();
});
it("returns null when session.compact returns null", async () => {
const compact = async () => null;
const session = { compact } as unknown as AgentSession;
const result = await compactSessionContext(session);
expect(result).toBeNull();
});
it("returns result with empty summary when session.compact returns object without summary", async () => {
const compact = async () => ({});
const session = { compact } as unknown as AgentSession;
const result = await compactSessionContext(session);
// Should still return a result with empty summary since the guard checks for object
expect(result).toEqual({ summary: "", tokensBefore: 0 });
});
});