fix(FN-000): rebuild layered memory system
This commit is contained in:
@@ -1191,7 +1191,6 @@ export class HeartbeatMonitor {
|
||||
// Document tools for persisting durable findings
|
||||
tools.push(createTaskDocumentWriteTool(taskStore, taskId));
|
||||
tools.push(createTaskDocumentReadTool(taskStore, taskId));
|
||||
|
||||
// Agent delegation tools — discover and delegate work to other agents
|
||||
tools.push(createListAgentsTool(this.store));
|
||||
tools.push(createDelegateTaskTool(this.store, taskStore));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createSendMessageTool, createReadMessagesTool, sendMessageParams, readMessagesParams } from "./agent-tools.js";
|
||||
import { createMemoryTools, createSendMessageTool, createReadMessagesTool, sendMessageParams, readMessagesParams } from "./agent-tools.js";
|
||||
import type { MessageStore, Message } from "@fusion/core";
|
||||
|
||||
// Mock logger
|
||||
@@ -15,6 +15,27 @@ vi.mock("./logger.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("createMemoryTools", () => {
|
||||
it("omits memory tools when memory is disabled", () => {
|
||||
expect(createMemoryTools("/repo", { memoryEnabled: false }).map((tool) => tool.name)).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits memory_append for read-only memory backends", () => {
|
||||
expect(createMemoryTools("/repo", { memoryBackendType: "readonly" }).map((tool) => tool.name)).toEqual([
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes memory_append for writable memory backends", () => {
|
||||
expect(createMemoryTools("/repo", { memoryBackendType: "file" }).map((tool) => tool.name)).toEqual([
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"memory_append",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* The parameter schemas are canonical here — executor.ts imports and reuses them.
|
||||
*/
|
||||
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core";
|
||||
import { isEphemeralAgent } from "@fusion/core";
|
||||
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, searchProjectMemory } from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
@@ -81,6 +82,30 @@ export const readMessagesParams = Type.Object({
|
||||
limit: Type.Optional(Type.Number({ description: "Max messages to return (default: 20)" })),
|
||||
});
|
||||
|
||||
export const memorySearchParams = Type.Object({
|
||||
query: Type.String({ description: "Search terms for durable project memory. Use focused keywords, not a full prompt." }),
|
||||
limit: Type.Optional(Type.Number({ description: "Maximum snippets to return (default: 5, max: 20)" })),
|
||||
});
|
||||
|
||||
export const memoryGetParams = Type.Object({
|
||||
path: Type.String({ description: "Memory path from memory_search, e.g. .fusion/memory/MEMORY.md or .fusion/memory/YYYY-MM-DD.md" }),
|
||||
startLine: Type.Optional(Type.Number({ description: "1-based start line (default: 1)" })),
|
||||
lineCount: Type.Optional(Type.Number({ description: "Number of lines to read (default: 120, max: 400)" })),
|
||||
});
|
||||
|
||||
export const memoryAppendParams = Type.Object({
|
||||
layer: Type.Union([
|
||||
Type.Literal("long-term"),
|
||||
Type.Literal("daily"),
|
||||
], { description: "long-term for durable conventions/decisions/pitfalls, daily for running notes/open loops" }),
|
||||
content: Type.String({ description: "Markdown content to append. Keep it concise and reusable." }),
|
||||
});
|
||||
|
||||
type MemoryToolSettings = {
|
||||
memoryBackendType?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// ── Tool factory functions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -281,6 +306,100 @@ export function createTaskDocumentReadTool(store: TaskStore, taskId: string): To
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings): ToolDefinition {
|
||||
return {
|
||||
name: "memory_search",
|
||||
label: "Search Memory",
|
||||
description:
|
||||
"Search durable project memory and return small snippets with file paths and line ranges. " +
|
||||
"Use this before memory_get; do not read all memory by default.",
|
||||
parameters: memorySearchParams,
|
||||
execute: async (_id: string, params: Static<typeof memorySearchParams>) => {
|
||||
const results = await searchProjectMemory(rootDir, {
|
||||
query: params.query,
|
||||
limit: params.limit,
|
||||
}, settings);
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "NONE" }],
|
||||
details: { results: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const text = results.map((result, index) => [
|
||||
`${index + 1}. ${result.path}:${result.lineStart}-${result.lineEnd} (score ${result.score}, ${result.backend})`,
|
||||
result.snippet,
|
||||
].join("\n")).join("\n\n");
|
||||
return { content: [{ type: "text" as const, text }], details: { results } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryGetTool(rootDir: string, settings?: MemoryToolSettings): ToolDefinition {
|
||||
return {
|
||||
name: "memory_get",
|
||||
label: "Get Memory",
|
||||
description:
|
||||
"Read a bounded line window from a memory file returned by memory_search. " +
|
||||
"Allowed files are .fusion/memory/MEMORY.md, .fusion/memory/YYYY-MM-DD.md, and legacy .fusion/memory.md.",
|
||||
parameters: memoryGetParams,
|
||||
execute: async (_id: string, params: Static<typeof memoryGetParams>) => {
|
||||
const result = await getProjectMemory(rootDir, {
|
||||
path: params.path,
|
||||
startLine: params.startLine,
|
||||
lineCount: params.lineCount,
|
||||
}, settings);
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
text: `${result.path}:${result.startLine}-${result.endLine} (${result.totalLines} total lines, ${result.backend})\n\n${result.content}`,
|
||||
}],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryAppendTool(rootDir: string): ToolDefinition {
|
||||
return {
|
||||
name: "memory_append",
|
||||
label: "Append Memory",
|
||||
description:
|
||||
"Append concise Markdown to project memory. Use long-term only for durable conventions/decisions/pitfalls; " +
|
||||
"use daily for running observations and open loops. Skip this tool when there is no reusable memory.",
|
||||
parameters: memoryAppendParams,
|
||||
execute: async (_id: string, params: Static<typeof memoryAppendParams>) => {
|
||||
await ensureOpenClawMemoryFiles(rootDir);
|
||||
const targetPath = params.layer === "long-term" ? memoryLongTermPath(rootDir) : dailyMemoryPath(rootDir);
|
||||
const content = params.content.trim();
|
||||
if (!content) {
|
||||
return { content: [{ type: "text" as const, text: "ERROR: memory content cannot be empty" }], details: {} };
|
||||
}
|
||||
|
||||
await appendFile(targetPath, `\n${content}\n`, "utf-8");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Appended to ${params.layer} memory.` }],
|
||||
details: { layer: params.layer },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings): ToolDefinition[] {
|
||||
if (settings?.memoryEnabled === false) {
|
||||
return [];
|
||||
}
|
||||
const tools = [
|
||||
createMemorySearchTool(rootDir, settings),
|
||||
createMemoryGetTool(rootDir, settings),
|
||||
];
|
||||
if (getMemoryBackendCapabilities(settings).writable) {
|
||||
tools.push(createMemoryAppendTool(rootDir));
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `reflect_on_performance` tool that asks the reflection service to
|
||||
* analyze recent agent performance and return actionable insights.
|
||||
|
||||
@@ -34,6 +34,7 @@ import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
|
||||
import {
|
||||
createDelegateTaskTool,
|
||||
createListAgentsTool,
|
||||
createMemoryTools,
|
||||
createReflectOnPerformanceTool,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool as sharedCreateTaskCreateTool,
|
||||
@@ -56,6 +57,9 @@ export {
|
||||
createTaskLogTool,
|
||||
delegateTaskParams,
|
||||
listAgentsParams,
|
||||
memoryAppendParams,
|
||||
memoryGetParams,
|
||||
memorySearchParams,
|
||||
sendMessageParams,
|
||||
taskCreateParams,
|
||||
taskLogParams,
|
||||
@@ -1416,6 +1420,7 @@ export class TaskExecutor {
|
||||
this.createSpawnAgentTool(task.id, worktreePath, settings),
|
||||
this.createTaskDocumentWriteTool(task.id),
|
||||
this.createTaskDocumentReadTool(task.id),
|
||||
...createMemoryTools(this.rootDir, settings),
|
||||
// Conditionally add agent self-reflection when enabled and task has an assigned agent.
|
||||
...reflectionTools,
|
||||
// Agent delegation tools — discover and delegate work to other agents.
|
||||
|
||||
@@ -181,6 +181,87 @@ describe("compactSessionContext", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptWithFallback context recovery", () => {
|
||||
it("tries compacting embedded prompt memory before full session compaction", async () => {
|
||||
const longMemory = Array.from({ length: 900 }, (_, index) => `- Durable memory item ${index}: ${"detail ".repeat(20)}`).join("\n");
|
||||
const promptText = [
|
||||
"Task prompt",
|
||||
"",
|
||||
"## Project Memory",
|
||||
"",
|
||||
longMemory,
|
||||
"",
|
||||
"## Begin",
|
||||
"",
|
||||
"Do the work.",
|
||||
].join("\n");
|
||||
const state: { error?: string } = {};
|
||||
const prompts: string[] = [];
|
||||
const prompt = vi.fn(async (nextPrompt: string) => {
|
||||
prompts.push(nextPrompt);
|
||||
if (prompt.mock.calls.length === 1) {
|
||||
state.error = "Your input exceeds the context window of this model. Please adjust your input and try again.";
|
||||
}
|
||||
});
|
||||
const compact = vi.fn();
|
||||
const session = {
|
||||
prompt,
|
||||
compact,
|
||||
state,
|
||||
} as unknown as AgentSession;
|
||||
|
||||
await promptWithFallback(session, promptText);
|
||||
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(prompts[1]!.length).toBeLessThan(prompts[0]!.length);
|
||||
expect(prompts[1]).toContain("Project memory compacted");
|
||||
expect(prompts[1]).toContain("## Begin");
|
||||
});
|
||||
|
||||
it("compacts and retries when session.prompt stores a context error in session.state.error", async () => {
|
||||
const state: { error?: string } = {};
|
||||
const prompt = vi.fn(async () => {
|
||||
if (prompt.mock.calls.length === 1) {
|
||||
state.error = "{\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"Your input exceeds the context window of this model. Please adjust your input and try again.\"}}";
|
||||
}
|
||||
});
|
||||
const compact = vi.fn(async () => {
|
||||
state.error = undefined;
|
||||
return { summary: "Compacted", tokensBefore: 120000 };
|
||||
});
|
||||
const session = {
|
||||
prompt,
|
||||
compact,
|
||||
state,
|
||||
} as unknown as AgentSession;
|
||||
|
||||
await promptWithFallback(session, "review this task");
|
||||
|
||||
expect(prompt).toHaveBeenCalledTimes(2);
|
||||
expect(compact).toHaveBeenCalledWith(COMPACTION_FALLBACK_INSTRUCTIONS);
|
||||
expect(state.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws swallowed non-context session errors without attempting compaction", async () => {
|
||||
const state: { error?: string } = {};
|
||||
const prompt = vi.fn(async () => {
|
||||
state.error = "429 Too Many Requests";
|
||||
});
|
||||
const compact = vi.fn();
|
||||
const session = {
|
||||
prompt,
|
||||
compact,
|
||||
state,
|
||||
} as unknown as AgentSession;
|
||||
|
||||
await expect(promptWithFallback(session, "review this task")).rejects.toThrow("429 Too Many Requests");
|
||||
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createKbAgent skills parameter", () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockResolveSessionSkills: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -45,6 +45,38 @@ export interface PromptableSession extends AgentSession {
|
||||
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
function getSessionStateError(session: AgentSession): string {
|
||||
const error = (session as any).state?.error;
|
||||
return typeof error === "string" ? error : "";
|
||||
}
|
||||
|
||||
function clearSessionStateError(session: AgentSession): void {
|
||||
const state = (session as any).state;
|
||||
if (!state || typeof state !== "object" || !("error" in state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
state.error = undefined;
|
||||
} catch {
|
||||
// Best effort only. Some session implementations may expose readonly state.
|
||||
}
|
||||
}
|
||||
|
||||
async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
clearSessionStateError(session);
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await (session.prompt as any)(prompt, options);
|
||||
}
|
||||
|
||||
const stateError = getSessionStateError(session);
|
||||
if (stateError) {
|
||||
throw new Error(stateError);
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
const maybePromptable = session as Partial<PromptableSession>;
|
||||
if (typeof maybePromptable.promptWithFallback === "function") {
|
||||
@@ -56,11 +88,7 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
|
||||
|
||||
console.error(`[pi] promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
|
||||
try {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await (session.prompt as any)(prompt, options);
|
||||
}
|
||||
await promptSessionAndCheck(session, prompt, options);
|
||||
console.error(`[pi] promptWithFallback: prompt completed`);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -70,7 +98,19 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
|
||||
}
|
||||
|
||||
// Context limit error — attempt auto-compaction and retry once
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, options);
|
||||
if (promptMemoryRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
if (promptMemoryRetry.error) {
|
||||
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||
if (!isContextLimitError(retryMessage)) {
|
||||
throw promptMemoryRetry.error;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[pi] promptWithFallback: context limit error — attempting auto-compaction`);
|
||||
await flushMemoryBeforeSessionCompaction(session);
|
||||
const compactResult = await compactSessionContext(session);
|
||||
if (!compactResult) {
|
||||
console.error(`[pi] promptWithFallback: compaction unavailable — propagating original error`);
|
||||
@@ -79,11 +119,7 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
|
||||
|
||||
console.error(`[pi] promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||
try {
|
||||
if (options === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await (session.prompt as any)(prompt, options);
|
||||
}
|
||||
await promptSessionAndCheck(session, prompt, options);
|
||||
console.error(`[pi] promptWithFallback: prompt completed after auto-compaction`);
|
||||
} catch (retryErr: unknown) {
|
||||
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||
@@ -116,6 +152,114 @@ export const COMPACTION_FALLBACK_INSTRUCTIONS = [
|
||||
"Discard verbose tool output, repeated attempts, and exploration history.",
|
||||
].join(" ");
|
||||
|
||||
const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
|
||||
|
||||
function compactMarkdownMemorySection(sectionBody: string): string {
|
||||
const lines = sectionBody.split("\n");
|
||||
const kept: string[] = [];
|
||||
let used = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimEnd();
|
||||
const normalized = trimmed.trimStart();
|
||||
const isUseful =
|
||||
normalized.startsWith("##")
|
||||
|| normalized.startsWith("- ")
|
||||
|| normalized.startsWith("* ")
|
||||
|| /^\d+\.\s/.test(normalized)
|
||||
|| normalized.length === 0;
|
||||
|
||||
if (!isUseful) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLength = used + trimmed.length + 1;
|
||||
if (nextLength > MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
|
||||
break;
|
||||
}
|
||||
|
||||
kept.push(trimmed);
|
||||
used = nextLength;
|
||||
}
|
||||
|
||||
const compacted = kept.join("\n").trim();
|
||||
if (compacted.length >= sectionBody.trim().length) {
|
||||
return sectionBody.trim();
|
||||
}
|
||||
|
||||
return [
|
||||
compacted,
|
||||
"",
|
||||
`<!-- Project memory compacted from ${sectionBody.length} characters to avoid context overflow. Read .fusion/memory.md later only if essential. -->`,
|
||||
].join("\n").trim();
|
||||
}
|
||||
|
||||
function compactPromptMemory(prompt: string): string | null {
|
||||
const sectionPattern = /(^|\n)(## (?:Project Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
|
||||
let changed = false;
|
||||
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => {
|
||||
const trimmedBody = body.trim();
|
||||
if (trimmedBody.length <= MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const compacted = compactMarkdownMemorySection(trimmedBody);
|
||||
if (compacted.length >= trimmedBody.length) {
|
||||
return match;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return `${prefix}${heading}${compacted}`;
|
||||
});
|
||||
|
||||
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
|
||||
}
|
||||
|
||||
async function retryWithCompactedPromptMemory(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<{ recovered: boolean; error?: unknown }> {
|
||||
const compactedPrompt = compactPromptMemory(prompt);
|
||||
if (!compactedPrompt) {
|
||||
return { recovered: false };
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[pi] promptWithFallback: retrying with compacted prompt memory (${prompt.length} → ${compactedPrompt.length} chars)`,
|
||||
);
|
||||
|
||||
try {
|
||||
await promptSessionAndCheck(session, compactedPrompt, options);
|
||||
console.error(`[pi] promptWithFallback: prompt completed after prompt-memory compaction`);
|
||||
return { recovered: true };
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[pi] promptWithFallback: retry after prompt-memory compaction failed: ${errorMessage}`);
|
||||
return { recovered: false, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
async function flushMemoryBeforeSessionCompaction(session: AgentSession): Promise<void> {
|
||||
if ((session as any).__fusionMemoryAppendAvailable !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flushPrompt = [
|
||||
"Before context compaction, preserve only unresolved durable memory if needed.",
|
||||
"If memory_append is available and you learned reusable project decisions, conventions, pitfalls, or open loops that are not already saved, append them now.",
|
||||
"Use layer=\"long-term\" for durable facts and layer=\"daily\" for running notes/open loops.",
|
||||
"If there is nothing durable to save, reply exactly: NONE.",
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
await promptSessionAndCheck(session, flushPrompt);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[pi] promptWithFallback: memory flush before compaction skipped: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact an agent session's context to free up the context window.
|
||||
*
|
||||
@@ -632,30 +776,35 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
|
||||
const { session } = sessionResult;
|
||||
(session as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
|
||||
const promptableSession = session as PromptableSession;
|
||||
|
||||
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => {
|
||||
try {
|
||||
if (promptOptions === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await (session.prompt as any)(prompt, promptOptions);
|
||||
}
|
||||
await promptSessionAndCheck(session, prompt, promptOptions);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.message || "";
|
||||
if (isContextLimitError(errorMessage)) {
|
||||
// Context limit error — attempt auto-compaction and retry once
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
|
||||
if (promptMemoryRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
if (promptMemoryRetry.error) {
|
||||
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||
if (!isContextLimitError(retryMessage)) {
|
||||
throw promptMemoryRetry.error;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[pi] promptWithFallback: context limit error — attempting auto-compaction`);
|
||||
await flushMemoryBeforeSessionCompaction(session);
|
||||
const compactResult = await compactSessionContext(session);
|
||||
if (compactResult) {
|
||||
console.error(`[pi] promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||
try {
|
||||
if (promptOptions === undefined) {
|
||||
await session.prompt(prompt);
|
||||
} else {
|
||||
await (session.prompt as any)(prompt, promptOptions);
|
||||
}
|
||||
await promptSessionAndCheck(session, prompt, promptOptions);
|
||||
return;
|
||||
} catch (retryErr: any) {
|
||||
const retryErrorMessage = retryErr?.message || "";
|
||||
@@ -682,6 +831,7 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
|
||||
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
|
||||
const fallbackSession = fallbackSessionResult.session as PromptableSession;
|
||||
(fallbackSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
|
||||
|
||||
if (options.defaultThinkingLevel) {
|
||||
fallbackSession.setThinkingLevel(options.defaultThinkingLevel as any);
|
||||
@@ -710,25 +860,29 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
|
||||
// Retry with fallback model, also with auto-compaction support
|
||||
try {
|
||||
if (promptOptions === undefined) {
|
||||
await fallbackSession.prompt(prompt);
|
||||
} else {
|
||||
await (fallbackSession.prompt as any)(prompt, promptOptions);
|
||||
}
|
||||
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
const fallbackErrorMessage = fallbackErr?.message || "";
|
||||
if (isContextLimitError(fallbackErrorMessage)) {
|
||||
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions);
|
||||
if (promptMemoryRetry.recovered) {
|
||||
return;
|
||||
}
|
||||
if (promptMemoryRetry.error) {
|
||||
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||
if (!isContextLimitError(retryMessage)) {
|
||||
throw promptMemoryRetry.error;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[pi] promptWithFallback: fallback session context limit error — attempting auto-compaction`);
|
||||
await flushMemoryBeforeSessionCompaction(fallbackSession);
|
||||
const compactResult = await compactSessionContext(fallbackSession);
|
||||
if (compactResult) {
|
||||
console.error(`[pi] promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
|
||||
try {
|
||||
if (promptOptions === undefined) {
|
||||
await fallbackSession.prompt(prompt);
|
||||
} else {
|
||||
await (fallbackSession.prompt as any)(prompt, promptOptions);
|
||||
}
|
||||
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||
return;
|
||||
} catch (retryErr: any) {
|
||||
const retryErrorMessage = retryErr?.message || "";
|
||||
|
||||
@@ -926,7 +926,7 @@ export class ProjectEngine {
|
||||
store.on("settings:updated", onStuckTimeoutChange);
|
||||
this.settingsHandlers.push(onStuckTimeoutChange);
|
||||
|
||||
// 5. Insight extraction settings change — sync automation
|
||||
// 5. Memory maintenance settings change — sync automations
|
||||
const onInsightSettingsChange = async ({
|
||||
settings: s,
|
||||
previous: prev,
|
||||
@@ -939,20 +939,30 @@ export class ProjectEngine {
|
||||
"insightExtractionSchedule",
|
||||
"insightExtractionMinIntervalMs",
|
||||
] as const;
|
||||
const dreamKeys = [
|
||||
"memoryDreamsEnabled",
|
||||
"memoryDreamsSchedule",
|
||||
] as const;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const changed = insightKeys.some((key) => (s as any)[key] !== (prev as any)[key]);
|
||||
if (!changed || !this.automationStore) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dreamsChanged = dreamKeys.some((key) => (s as any)[key] !== (prev as any)[key]);
|
||||
if ((!changed && !dreamsChanged) || !this.automationStore) return;
|
||||
|
||||
try {
|
||||
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
||||
if (typeof syncInsightExtractionAutomation === "function") {
|
||||
const { syncInsightExtractionAutomation, syncMemoryDreamsAutomation } = await import("@fusion/core");
|
||||
if (changed && typeof syncInsightExtractionAutomation === "function") {
|
||||
await syncInsightExtractionAutomation(this.automationStore, s);
|
||||
runtimeLog.log("Insight extraction automation synced with settings");
|
||||
}
|
||||
if (dreamsChanged && typeof syncMemoryDreamsAutomation === "function") {
|
||||
await syncMemoryDreamsAutomation(this.automationStore, s);
|
||||
runtimeLog.log("Memory dreams automation synced with settings");
|
||||
}
|
||||
} catch (err) {
|
||||
runtimeLog.warn(
|
||||
"Failed to sync insight extraction automation:",
|
||||
"Failed to sync memory maintenance automation:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { reviewerLog } from "./logger.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { createMemoryGetTool, createMemorySearchTool } from "./agent-tools.js";
|
||||
|
||||
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
||||
|
||||
@@ -329,6 +330,10 @@ export async function reviewStep(
|
||||
cwd,
|
||||
systemPrompt: reviewerSystemPrompt,
|
||||
tools: "readonly",
|
||||
customTools: options.rootDir ? [
|
||||
createMemorySearchTool(options.rootDir),
|
||||
createMemoryGetTool(options.rootDir),
|
||||
] : undefined,
|
||||
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
|
||||
onThinking: agentLogger?.onThinking,
|
||||
onToolStart: agentLogger?.onToolStart,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import { createTaskDocumentWriteTool, createTaskDocumentReadTool } from "./agent-tools.js";
|
||||
import { createMemoryTools, createTaskDocumentWriteTool, createTaskDocumentReadTool } from "./agent-tools.js";
|
||||
|
||||
const stepExecLog = createLogger("step-session-executor");
|
||||
|
||||
@@ -786,6 +786,7 @@ export class StepSessionExecutor {
|
||||
createTaskDocumentReadTool(this.options.store, taskDetail.id),
|
||||
]
|
||||
: [];
|
||||
const memoryTools = createMemoryTools(this.options.rootDir, settings);
|
||||
|
||||
// Create fresh agent session for this attempt
|
||||
// Resolve executor model using canonical lane hierarchy:
|
||||
@@ -816,7 +817,7 @@ export class StepSessionExecutor {
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel,
|
||||
customTools: [...pluginTools, ...documentTools],
|
||||
customTools: [...pluginTools, ...documentTools, ...memoryTools],
|
||||
onText: (delta) => {
|
||||
agentLogger.onText(delta);
|
||||
stuckTaskDetector?.recordActivity(trackingKey);
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { StuckTaskDetector } from "./stuck-task-detector.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createMemoryTools,
|
||||
createTaskDocumentReadTool,
|
||||
createTaskDocumentWriteTool,
|
||||
} from "./agent-tools.js";
|
||||
@@ -651,6 +652,7 @@ export class TriageProcessor {
|
||||
}),
|
||||
createTaskDocumentWriteTool(this.store, task.id),
|
||||
createTaskDocumentReadTool(this.store, task.id),
|
||||
...createMemoryTools(this.rootDir, settings),
|
||||
this.createReviewSpecTool(
|
||||
task.id,
|
||||
promptPath,
|
||||
|
||||
Reference in New Issue
Block a user