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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user