Files
fusion/packages/dashboard/app/api/agents/agents.ts
gsxdsm 5a1853cac6 FN-8934: add memory pre-steering across agent prompts
Add bounded memory-first guidance and consolidation observability across agent workflows.

- Apply mode-aware memory pre-steering to execution, triage, reviewer, heartbeat, and merger prompts.
- Export and test shared memory nudges while documenting memory inclusion settings.
- Expose agent memory consolidation history through dashboard API and detail UI.

Files changed:
 .changeset/fn-8934-memory-pre-steering.md          |  7 ++
 docs/agents.md                                     |  4 +
 docs/settings-reference.md                         |  2 +
 .../core/src/__tests__/memory-pre-steering.test.ts | 28 +++++++
 packages/core/src/__tests__/project-memory.test.ts | 22 ++++++
 packages/core/src/index.gate.ts                    |  7 ++
 packages/core/src/index.ts                         |  7 ++
 packages/core/src/memory/index.ts                  |  1 +
 packages/core/src/memory/memory-pre-steering.ts    | 41 ++++++++++
 packages/core/src/memory/project-memory.ts         | 19 +++++
 packages/dashboard/app/api/agents/agents.ts        | 12 +++
 packages/dashboard/app/api/legacy.ts               |  2 +
 .../dashboard/app/components/AgentDetailView.css   | 41 ++++++++++
 .../dashboard/app/components/AgentDetailView.tsx   | 56 +++++++++++++-
 ...etailView.memory-consolidation-history.test.tsx | 58 +++++++++++++++
 .../__tests__/AgentDetailView.test-helpers.ts      |  3 +
 .../register-agent-memory-consolidations.test.ts   | 87 ++++++++++++++++++++++
 .../register-agent-reflection-rating-routes.ts     | 36 +++++++++
 .../memory-pre-steering-injection.test.ts          | 50 +++++++++++++
 packages/engine/src/__tests__/reviewer.test.ts     | 18 +++++
 packages/engine/src/__tests__/triage.test.ts       | 18 ++++-
 packages/engine/src/agent-heartbeat.ts             | 14 +++-
 .../engine/src/agents/agent-heartbeat-prompts.ts   |  2 +
 packages/engine/src/agents/agent-instructions.ts   | 16 ++--
 packages/engine/src/execution/reviewer.ts          | 31 +++++---
 packages/engine/src/executor/execution-prompt.ts   |  6 +-
 packages/engine/src/merger.ts                      |  9 ++-
 packages/engine/src/triage.ts                      | 12 ++-
 28 files changed, 577 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-8934

Fusion-Task-Lineage: a062cc6b-1277-4d36-9926-b3ecba4945eb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-11 04:57:14 -07:00

290 lines
12 KiB
TypeScript

/**
* FNXC:CodeOrganization 2026-07-19-12:00:
* Agent CRUD / soul / memory client API peeled from legacy.ts.
*/
import type {
Agent,
AgentDetail,
AgentCapability,
AgentState,
AgentHeartbeatEvent,
AgentHeartbeatRun,
AgentCreateInput,
AgentUpdateInput,
AgentTaskSession,
AgentStats,
HeartbeatInvocationSource,
OrgTreeNode,
AgentReflection,
AgentPerformanceSummary,
ReflectionTrigger,
AgentBudgetStatus,
AgentLogEntry,
} from "@fusion/core";
import type { MemoryFileInfo } from "../system/memory.js";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus, AgentLogEntry };
import { api, buildApiUrl } from "../client/client.js";
import type { FetchOptions } from "../client/client.js";
import { withProjectId } from "../client/health.js";
import { withTokenHeader } from "../../auth";
import { dedupe } from "../client/dedupe.js";
// ── Agent API ────────────────────────────────────────────────────────────
/*
FNXC:AgentHeartbeatControls 2026-07-23-13:00:
Heartbeat enablement defaults to enabled when no explicit value exists. The agent PATCH route replaces runtimeConfig rather than merging it, so every dashboard surface must share this immutable helper to preserve interval, model, concurrency, and future configuration keys without changing lifecycle state.
*/
export function isAgentHeartbeatEnabled(agent: Pick<Agent, "runtimeConfig">): boolean {
return agent.runtimeConfig?.enabled !== false;
}
export function withAgentHeartbeatEnabled<T extends Pick<Agent, "runtimeConfig">>(agent: T, enabled: boolean): AgentUpdateInput["runtimeConfig"] {
return { ...(agent.runtimeConfig ?? {}), enabled };
}
export interface MemoryConsolidationEvent {
id: string;
timestamp: string;
mutationType: "memory:consolidation-completed" | "memory:consolidation-skipped" | "memory:consolidation-failed";
runId: string;
metadata?: Record<string, unknown>;
}
export function fetchAgentMemoryConsolidations(agentId: string, limit = 50, projectId?: string): Promise<{ agentId: string; events: MemoryConsolidationEvent[] }> {
return api<{ agentId: string; events: MemoryConsolidationEvent[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory-consolidations?limit=${Math.min(Math.max(Math.trunc(limit), 1), 100)}`, projectId));
}
export interface AgentPromptSizePoint {
runId: string;
createdAt: string;
systemChars: number;
execChars: number;
totalChars: number;
}
/** Fetch workspace sub-repos for a project */
export function fetchWorkspaceRepos(projectId?: string): Promise<{ repos: string[] }> {
return api<{ repos: string[] }>(withProjectId("/git/workspace-repos", projectId));
}
/** Fetch all agents, optionally filtered by state or role */
export function fetchAgents(
filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean },
projectId?: string,
options?: FetchOptions,
): Promise<Agent[]> {
const params = new URLSearchParams();
if (filter?.state) params.set("state", filter.state);
if (filter?.role) params.set("role", filter.role);
if (filter?.includeEphemeral === true) params.set("includeEphemeral", "true");
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
const path = `/agents${query}`;
return dedupe(path, () => api<Agent[]>(path), options);
}
/** Fetch a single agent with heartbeat history */
export function fetchAgent(agentId: string, projectId?: string): Promise<AgentDetail> {
return api<AgentDetail>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId));
}
/** Create a new agent */
export function createAgent(input: AgentCreateInput, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId("/agents", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update an agent */
export function updateAgent(agentId: string, updates: AgentUpdateInput, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Upload an agent avatar image. */
export async function uploadAgentAvatar(agentId: string, file: File, projectId?: string): Promise<Agent> {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(buildApiUrl(withProjectId(`/agents/${encodeURIComponent(agentId)}/avatar`, projectId)), {
method: "POST",
headers: withTokenHeader(),
body: formData,
});
const data = await res.json();
if (!res.ok) {
throw new Error((data as { error?: string }).error || "Avatar upload failed");
}
return data as Agent;
}
/** Delete an agent avatar image. */
export function deleteAgentAvatar(agentId: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/avatar`, projectId), {
method: "DELETE",
});
}
/** Backfill an existing agent onto the default heartbeat procedure file. */
export function upgradeAgentHeartbeatProcedure(
agentId: string,
projectId?: string,
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
return api(
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
{ method: "POST" },
);
}
/** Update agent custom instructions */
export function updateAgentInstructions(
agentId: string,
instructions: { instructionsPath?: string; instructionsText?: string },
projectId?: string,
): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/instructions`, projectId), {
method: "PATCH",
body: JSON.stringify(instructions),
});
}
/** Fetch agent soul/personality text */
export function fetchAgentSoul(agentId: string, projectId?: string): Promise<{ soul: string | null }> {
return api<{ soul: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId));
}
/** Update agent soul/personality text */
export function updateAgentSoul(agentId: string, soul: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId), {
method: "PATCH",
body: JSON.stringify({ soul }),
});
}
/** Fetch per-agent memory text */
export function fetchAgentMemory(agentId: string, projectId?: string): Promise<{ memory: string | null }> {
return api<{ memory: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId));
}
/** Update per-agent memory text */
export function updateAgentMemory(agentId: string, memory: string, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId), {
method: "PATCH",
body: JSON.stringify({ memory }),
});
}
/** List file-based memory entries for a specific agent */
export function fetchAgentMemoryFiles(agentId: string, projectId?: string): Promise<{ files: MemoryFileInfo[] }> {
return api<{ files: MemoryFileInfo[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/files`, projectId));
}
/** Read one file-based memory entry for a specific agent */
export function fetchAgentMemoryFile(agentId: string, path: string, projectId?: string): Promise<{ path: string; content: string }> {
const query = `path=${encodeURIComponent(path)}`;
return api<{ path: string; content: string }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file?${query}`, projectId));
}
/** Save one file-based memory entry for a specific agent */
export function saveAgentMemoryFile(agentId: string, path: string, content: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file`, projectId), {
method: "PUT",
body: JSON.stringify({ path, content }),
});
}
/** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
method: "POST",
body: JSON.stringify({ state }),
});
}
/** Delete an agent */
export function deleteAgent(agentId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
method: "DELETE",
});
}
/** Record a heartbeat for an agent */
export function recordAgentHeartbeat(
agentId: string,
status: "ok" | "missed" | "recovered" = "ok",
projectId?: string,
): Promise<AgentHeartbeatEvent> {
return api<AgentHeartbeatEvent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/heartbeat`, projectId), {
method: "POST",
body: JSON.stringify({ status }),
});
}
/** Fetch heartbeat history for an agent */
export function fetchAgentHeartbeats(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatEvent[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
}
/** Fetch heartbeat runs for an agent */
export function fetchAgentRuns(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatRun[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentHeartbeatRun[]>(`/agents/${encodeURIComponent(agentId)}/runs${query}`);
}
/** Fetch a single heartbeat run detail */
export function fetchAgentRunDetail(agentId: string, runId: string, projectId?: string): Promise<AgentHeartbeatRun> {
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
}
/** Fetch agent logs for a specific run's time window */
export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: string): Promise<AgentLogEntry[]> {
return api<AgentLogEntry[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId));
}
/** Fetch recent prompt size points for an agent */
export function fetchAgentPromptSizes(agentId: string, limit?: number, projectId?: string): Promise<AgentPromptSizePoint[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentPromptSizePoint[]>(`/agents/${encodeURIComponent(agentId)}/prompt-sizes${query}`);
}
/** Manually start a heartbeat run for an agent */
export function startAgentRun(
agentId: string,
projectId?: string,
options?: { source?: HeartbeatInvocationSource; triggerDetail?: string },
): Promise<AgentHeartbeatRun> {
const source = options?.source ?? "manual";
const triggerDetail = options?.triggerDetail ?? "Agent activated via dashboard";
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
method: "POST",
body: JSON.stringify({ source, triggerDetail }),
});
}
/** Stop an active heartbeat run for an agent */
export function stopAgentRun(
agentId: string,
projectId?: string,
): Promise<{ ok: boolean; runId?: string; message?: string }> {
return api<{ ok: boolean; runId?: string; message?: string }>(
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/stop`, projectId),
{
method: "POST",
},
);
}