feat(FN-3059): align provider metadata and documentation

Merged feat(FN-3059) which aligns provider metadata and documentation across the codebase, updating README and getting-started docs plus refinements to the CustomProviderForm and ProviderIcon dashboard components.

Fusion-Task-Id: FN-3059
This commit is contained in:
Fusion
2026-05-01 17:39:47 -07:00
committed by gsxdsm
parent 25ffb7cee1
commit b3e2b614c8
15 changed files with 138 additions and 155 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Hide deprecated Google Gemini CLI/Antigravity auth providers from dashboard onboarding and Settings while keeping supported Google/Gemini API-key, Google Generative AI, Vertex, and Cloud Code paths intact. Also documents the internal pi-coding-agent v0.71.x upgrade plan for follow-up dependency bump work.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Remove redundant `fn_identity` heartbeat tool and trim the inline Identity Snapshot to presence flags + content hashes. Full soul/instructions/memory content is already loaded in the system prompt's Custom Instructions section, so per-tick previews were duplicating multi-KB of context for no verification benefit. Saves prompt tokens on every heartbeat run.

View File

@@ -280,7 +280,7 @@ for full precedence and reset/revocation options.
On first launch, Fusion opens the **onboarding wizard** with three guided steps: On first launch, Fusion opens the **onboarding wizard** with three guided steps:
1. **AI Setup** — Use a simplified quick-start provider list (recommended providers plus any already-connected providers), then expand **Advanced provider settings** only if you need additional providers or setup details. You only need one provider to get started. 1. **AI Setup** — Use a simplified quick-start provider list (recommended providers plus any already-connected providers), then expand **Advanced provider settings** only if you need additional providers or setup details. You only need one provider to get started. Deprecated Google Gemini CLI / Antigravity provider entries are intentionally hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code paths remain supported.
2. **GitHub (Optional)** — Connect GitHub for issue import and PR management 2. **GitHub (Optional)** — Connect GitHub for issue import and PR management
3. **First Task** — Create your first task or import from GitHub (if no project is active, onboarding first prompts you to register/select a project directory) 3. **First Task** — Create your first task or import from GitHub (if no project is active, onboarding first prompts you to register/select a project directory)
@@ -327,7 +327,7 @@ Fusion supports OAuth-based authentication for AI providers configured via **Set
- **OpenAI Codex** — Authenticates via Settings OAuth flow with secure state validation - **OpenAI Codex** — Authenticates via Settings OAuth flow with secure state validation
- **Factory AI — via Droid CLI** *(optional)* — requires local `droid` install + `droid auth login`, then enable the provider in **Settings → Authentication** and restart Fusion - **Factory AI — via Droid CLI** *(optional)* — requires local `droid` install + `droid auth login`, then enable the provider in **Settings → Authentication** and restart Fusion
- **Other providers** — Authenticate via API key entry in Settings - **Other providers** — Authenticate via API key entry in Settings (including Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code aliases)
- **pi authentication** — Handled separately via the `pi` CLI (`/login`) or `ANTHROPIC_API_KEY` environment variable - **pi authentication** — Handled separately via the `pi` CLI (`/login`) or `ANTHROPIC_API_KEY` environment variable
### Model system ### Model system

View File

@@ -76,7 +76,7 @@ fn dashboard
On first launch, Fusion opens an onboarding wizard with three steps: On first launch, Fusion opens an onboarding wizard with three steps:
1. **AI Setup** — choose a provider and authenticate (you only need one to start) 1. **AI Setup** — choose a provider and authenticate (you only need one to start). Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available.
2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows 2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows
3. **First Task** — create your first task or import one from GitHub 3. **First Task** — create your first task or import one from GitHub

View File

@@ -55,5 +55,4 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts`
| Tool | Purpose | Parameters | | Tool | Purpose | Parameters |
|---|---|---| |---|---|---|
| `fn_identity` | Return loaded soul/instructions/memory summary for this heartbeat tick (must be called first) | none |
| `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) | | `fn_heartbeat_done` | Signal end of heartbeat run with optional summary | `summary?` (string) |

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import type { CustomProviderConfig, CustomProviderModelInput } from "../api"; import type { CustomProviderConfig, CustomProviderModelInput } from "../api";
import "./CustomProviderForm.css"; import "./CustomProviderForm.css";
// Keep in sync with BUILT_IN_PROVIDER_IDS in register-custom-provider-routes.ts // Reserved built-in IDs (including hidden/deprecated aliases) to prevent custom-provider collisions.
export const BUILT_IN_PROVIDER_IDS = new Set<string>([ export const BUILT_IN_PROVIDER_IDS = new Set<string>([
"anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity", "anthropic", "claude-cli", "pi-claude-cli", "openai", "openai-codex", "google", "gemini", "google-antigravity",
"antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai", "antigravity", "google-vertex", "vertex", "google-cloud-code", "cloud-code", "google-gemini-cli", "google-generative-ai",

View File

@@ -26,6 +26,7 @@ import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LoginInstructions } from "./LoginInstructions"; import { LoginInstructions } from "./LoginInstructions";
import { CustomProviderForm } from "./CustomProviderForm"; import { CustomProviderForm } from "./CustomProviderForm";
import { appendTokenQuery } from "../auth"; import { appendTokenQuery } from "../auth";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
const mapLegacyCustomProviderToConfig = ( const mapLegacyCustomProviderToConfig = (
provider: CustomProvider | CustomProviderConfig, provider: CustomProvider | CustomProviderConfig,
@@ -736,12 +737,13 @@ export function ModelOnboardingModal({
const loadAuthStatus = useCallback(async () => { const loadAuthStatus = useCallback(async () => {
try { try {
const { providers, ghCli } = await fetchAuthStatus(); const { providers, ghCli } = await fetchAuthStatus();
setAuthProviders(providers); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
setAuthProviders(visibleProviders);
setGhCliStatus(ghCli); setGhCliStatus(ghCli);
setLoginInstructions((prev) => { setLoginInstructions((prev) => {
const next: Record<string, string> = {}; const next: Record<string, string> = {};
for (const [providerId, instructions] of Object.entries(prev)) { for (const [providerId, instructions] of Object.entries(prev)) {
const provider = providers.find((candidate) => candidate.id === providerId); const provider = visibleProviders.find((candidate) => candidate.id === providerId);
if (provider && !provider.authenticated && provider.loginInProgress) { if (provider && !provider.authenticated && provider.loginInProgress) {
next[providerId] = instructions; next[providerId] = instructions;
} }
@@ -755,7 +757,7 @@ export function ModelOnboardingModal({
if (outcome !== "pending") { if (outcome !== "pending") {
continue; continue;
} }
const provider = providers.find((candidate) => candidate.id === providerId); const provider = visibleProviders.find((candidate) => candidate.id === providerId);
if (!provider?.loginInProgress) { if (!provider?.loginInProgress) {
delete next[providerId]; delete next[providerId];
changed = true; changed = true;
@@ -766,7 +768,7 @@ export function ModelOnboardingModal({
// Remove from skippedProviders when a provider becomes authenticated // Remove from skippedProviders when a provider becomes authenticated
setSkippedProviders((prev) => { setSkippedProviders((prev) => {
const updated = { ...prev }; const updated = { ...prev };
for (const p of providers) { for (const p of visibleProviders) {
if (p.authenticated && updated[p.id]) { if (p.authenticated && updated[p.id]) {
delete updated[p.id]; delete updated[p.id];
} }
@@ -1129,9 +1131,10 @@ export function ModelOnboardingModal({
try { try {
const { providers, ghCli } = await fetchAuthStatus(); const { providers, ghCli } = await fetchAuthStatus();
setAuthProviders(providers); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
setAuthProviders(visibleProviders);
setGhCliStatus(ghCli); setGhCliStatus(ghCli);
const provider = providers.find((p) => p.id === providerId); const provider = visibleProviders.find((p) => p.id === providerId);
if (provider?.authenticated) { if (provider?.authenticated) {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);

View File

@@ -659,12 +659,14 @@ const providerConfig: Record<
google: { component: GeminiIcon, color: "var(--provider-gemini)" }, google: { component: GeminiIcon, color: "var(--provider-gemini)" },
gemini: { component: GeminiIcon, color: "var(--provider-gemini)" }, // Gemini alias family gemini: { component: GeminiIcon, color: "var(--provider-gemini)" }, // Gemini alias family
// Deprecated upstream in pi-coding-agent 0.71+, retained for legacy usage/auth history rendering.
"google-antigravity": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini" }, "google-antigravity": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini" },
antigravity: { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini" }, antigravity: { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini" },
"google-vertex": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Vertex AI" }, "google-vertex": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Vertex AI" },
vertex: { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Vertex AI" }, vertex: { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Vertex AI" },
"google-cloud-code": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Cloud Code" }, "google-cloud-code": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Cloud Code" },
"cloud-code": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Cloud Code" }, "cloud-code": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Cloud Code" },
// Deprecated upstream in pi-coding-agent 0.71+, retained for legacy usage/auth history rendering.
"google-gemini-cli": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini CLI" }, "google-gemini-cli": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Gemini CLI" },
"google-generative-ai": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Generative AI" }, "google-generative-ai": { component: GeminiIcon, color: "var(--provider-gemini)", label: "Google Generative AI" },

View File

@@ -41,6 +41,7 @@ import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
import { useNodes } from "../hooks/useNodes"; import { useNodes } from "../hooks/useNodes";
import { NodeHealthDot } from "./NodeHealthDot"; import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h). // GitHub star count — fetched once per session, cached in localStorage (1 h).
@@ -654,11 +655,12 @@ export function SettingsModal({
const loadAuthStatus = useCallback(async () => { const loadAuthStatus = useCallback(async () => {
try { try {
const { providers } = await fetchAuthStatus(); const { providers } = await fetchAuthStatus();
setAuthProviders(providers); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
setAuthProviders(visibleProviders);
setLoginInstructions((prev) => { setLoginInstructions((prev) => {
const next: Record<string, string> = {}; const next: Record<string, string> = {};
for (const [providerId, instructions] of Object.entries(prev)) { for (const [providerId, instructions] of Object.entries(prev)) {
const provider = providers.find((candidate) => candidate.id === providerId); const provider = visibleProviders.find((candidate) => candidate.id === providerId);
if (provider && !provider.authenticated) { if (provider && !provider.authenticated) {
next[providerId] = instructions; next[providerId] = instructions;
} }
@@ -910,8 +912,9 @@ export function SettingsModal({
pollIntervalRef.current = setInterval(async () => { pollIntervalRef.current = setInterval(async () => {
try { try {
const { providers } = await fetchAuthStatus(); const { providers } = await fetchAuthStatus();
setAuthProviders(providers); const visibleProviders = filterVisibleOnboardingAndSettingsProviders(providers);
const provider = providers.find((p) => p.id === providerId); setAuthProviders(visibleProviders);
const provider = visibleProviders.find((p) => p.id === providerId);
if (provider?.authenticated) { if (provider?.authenticated) {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);

View File

@@ -249,6 +249,26 @@ describe("ModelOnboardingModal", () => {
}); });
}); });
it("hides deprecated google CLI and antigravity providers while keeping supported Google/Gemini entries", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "google", name: "Google", authenticated: false, type: "api_key" },
{ id: "gemini", name: "Gemini", authenticated: false, type: "api_key" },
{ id: "google-antigravity", name: "Google Antigravity", authenticated: false, type: "oauth" },
{ id: "antigravity", name: "Antigravity", authenticated: false, type: "oauth" },
{ id: "google-gemini-cli", name: "Google Gemini CLI", authenticated: false, type: "cli" },
],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
expect(await screen.findByTestId("onboarding-provider-card-google")).toBeInTheDocument();
expect(screen.getByTestId("onboarding-provider-card-gemini")).toBeInTheDocument();
expect(screen.queryByTestId("onboarding-provider-card-google-antigravity")).not.toBeInTheDocument();
expect(screen.queryByTestId("onboarding-provider-card-antigravity")).not.toBeInTheDocument();
expect(screen.queryByText("Google Gemini CLI")).not.toBeInTheDocument();
});
it("shows Back and Next buttons on middle steps", async () => { it("shows Back and Next buttons on middle steps", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({ mockFetchAuthStatus.mockResolvedValueOnce({
providers: [ providers: [

View File

@@ -827,6 +827,27 @@ describe("SettingsModal", () => {
expect(screen.getByTestId("auth-status-openai")).toHaveTextContent("✗ Not connected"); expect(screen.getByTestId("auth-status-openai")).toHaveTextContent("✗ Not connected");
}); });
it("hides deprecated Google CLI and antigravity auth providers", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [
{ id: "google", name: "Google", authenticated: false, type: "api_key" },
{ id: "gemini", name: "Gemini", authenticated: false, type: "api_key" },
{ id: "google-antigravity", name: "Google Antigravity", authenticated: false, type: "oauth" },
{ id: "antigravity", name: "Antigravity", authenticated: false, type: "oauth" },
{ id: "google-gemini-cli", name: "Google Gemini CLI", authenticated: false, type: "cli" },
],
});
renderModal();
await waitForSettingsModalReady();
expect(screen.getByTestId("auth-provider-icon-google")).toBeInTheDocument();
expect(screen.getByTestId("auth-provider-icon-gemini")).toBeInTheDocument();
expect(screen.queryByTestId("auth-provider-icon-google-antigravity")).not.toBeInTheDocument();
expect(screen.queryByTestId("auth-provider-icon-antigravity")).not.toBeInTheDocument();
expect(screen.queryByText("Google Gemini CLI")).not.toBeInTheDocument();
});
it("scrolls settings content to top after OAuth login succeeds", async () => { it("scrolls settings content to top after OAuth login succeeds", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockLoginProvider.mockResolvedValue({ url: "https://example.com/auth", instructions: "" }); mockLoginProvider.mockResolvedValue({ url: "https://example.com/auth", instructions: "" });

View File

@@ -0,0 +1,17 @@
const HIDDEN_ONBOARDING_AND_SETTINGS_PROVIDER_IDS = new Set([
"google-antigravity",
"antigravity",
"google-gemini-cli",
]);
export function isProviderVisibleInOnboardingAndSettings(providerId: string): boolean {
return !HIDDEN_ONBOARDING_AND_SETTINGS_PROVIDER_IDS.has(providerId);
}
export function filterVisibleOnboardingAndSettingsProviders<T extends { id: string }>(
providers: T[],
): T[] {
return providers.filter((provider) => isProviderVisibleInOnboardingAndSettings(provider.id));
}
export { HIDDEN_ONBOARDING_AND_SETTINGS_PROVIDER_IDS };

View File

@@ -2886,8 +2886,8 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.systemPrompt).toContain("fn_task_document_write"); expect(callArgs.systemPrompt).toContain("fn_task_document_write");
expect(callArgs.tools).toBe("readonly"); expect(callArgs.tools).toBe("readonly");
// Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task, // Tools: fn_task_create, fn_task_log, fn_task_document_write, fn_task_document_read, fn_list_agents, fn_delegate_task,
// fn_memory_search, fn_memory_get, fn_memory_append, fn_identity, fn_heartbeat_done // fn_memory_search, fn_memory_get, fn_memory_append, fn_heartbeat_done
expect(callArgs.customTools).toHaveLength(11); expect(callArgs.customTools).toHaveLength(10);
expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![0]!.name).toBe("fn_task_create");
expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log");
expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write"); expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write");
@@ -2897,10 +2897,8 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.customTools![6]!.name).toBe("fn_memory_search"); expect(callArgs.customTools![6]!.name).toBe("fn_memory_search");
expect(callArgs.customTools![7]!.name).toBe("fn_memory_get"); expect(callArgs.customTools![7]!.name).toBe("fn_memory_get");
expect(callArgs.customTools![8]!.name).toBe("fn_memory_append"); expect(callArgs.customTools![8]!.name).toBe("fn_memory_append");
// fn_identity appears before fn_heartbeat_done
expect(callArgs.customTools![9]!.name).toBe("fn_identity");
// fn_heartbeat_done is last (terminal tool) // fn_heartbeat_done is last (terminal tool)
expect(callArgs.customTools![10]!.name).toBe("fn_heartbeat_done"); expect(callArgs.customTools![9]!.name).toBe("fn_heartbeat_done");
}); });
it("includes memory instructions even when agent has no custom instructions", async () => { it("includes memory instructions even when agent has no custom instructions", async () => {
@@ -6053,7 +6051,7 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
expect(savedRun.heartbeatProcedureSource).toBe("default"); expect(savedRun.heartbeatProcedureSource).toBe("default");
// The execution prompt should contain the procedure text before the no-task action menu // The execution prompt should contain the procedure text before the no-task action menu
expect(savedRun.executionPrompt).toContain("fn_identity"); expect(savedRun.executionPrompt).toContain("Identity Snapshot");
expect(savedRun.executionPrompt).toContain("Heartbeat Procedure"); expect(savedRun.executionPrompt).toContain("Heartbeat Procedure");
// The wake delta header should appear before the action menu items // The wake delta header should appear before the action menu items
const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure"); const procedureIdx = savedRun.executionPrompt!.indexOf("Heartbeat Procedure");
@@ -6114,34 +6112,26 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
expect(taskDescIdx).toBeGreaterThanOrEqual(0); expect(taskDescIdx).toBeGreaterThanOrEqual(0);
expect(procedureIdx).toBeLessThan(taskDescIdx); expect(procedureIdx).toBeLessThan(taskDescIdx);
// fn_identity instruction should appear in the execution prompt // Identity Snapshot should appear in the execution prompt
expect(savedRun.executionPrompt).toContain("fn_identity"); expect(savedRun.executionPrompt).toContain("## Identity Snapshot");
expect(result.status).toBe("completed"); expect(result.status).toBe("completed");
}); });
it("fn_identity tool returns correct agent identity information", async () => { it("does not register a fn_identity tool (removed in favor of inline snapshot)", async () => {
const store = createStoreWithAgent({ soul: "I am a senior executor.", memory: "Always log blockers." }); const store = createStoreWithAgent({ soul: "I am a senior executor.", memory: "Always log blockers." });
let capturedIdentityTool: any; let capturedTools: any[] | undefined;
const mockSession = createMockAgentSession(); const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockImplementation(async (opts: any) => { mockedCreateFnAgent.mockImplementation(async (opts: any) => {
capturedIdentityTool = opts.customTools?.find((t: any) => t.name === "fn_identity"); capturedTools = opts.customTools;
return { session: mockSession as any }; return { session: mockSession as any };
}); });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(capturedIdentityTool).toBeDefined(); expect(capturedTools).toBeDefined();
expect(capturedIdentityTool.name).toBe("fn_identity"); expect(capturedTools!.find((t) => t.name === "fn_identity")).toBeUndefined();
// Call the tool and verify output structure
const toolResult = await capturedIdentityTool.execute("call-1", {});
expect(toolResult.content[0].text).toContain("agentId: agent-001");
expect(toolResult.content[0].text).toContain("name: Test Agent");
expect(toolResult.details.soulPresent).toBe(true);
expect(toolResult.details.memoryPresent).toBe(true);
expect(toolResult.details.soulPreview).toContain("I am a senior executor.");
}); });
it("inlines the Identity Snapshot block into the execution prompt for runtime-agnostic delivery", async () => { it("inlines the Identity Snapshot block into the execution prompt for runtime-agnostic delivery", async () => {
@@ -6167,12 +6157,15 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
// Snapshot header + identity fields appear in the execution prompt body itself, // Snapshot header + identity fields appear in the execution prompt body itself,
// so non-pi runtimes (openclaw/hermes/paperclip) that may not propagate // so non-pi runtimes (openclaw/hermes/paperclip) that may not propagate
// customTools still see the agent's identity every tick. // customTools still see the agent's identity every tick. Snapshot carries
// presence flags + content hashes only — full content lives in the system
// prompt's Custom Instructions section.
expect(exec).toContain("## Identity Snapshot"); expect(exec).toContain("## Identity Snapshot");
expect(exec).toContain("- agentId: agent-001"); expect(exec).toContain("- agentId: agent-001");
expect(exec).toContain("- soul: loaded"); expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
expect(exec).toContain("- memory: loaded"); expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
expect(exec).toContain("I keep momentum across stalled tasks."); // Snapshot must NOT contain full preview content (that lives in the system prompt)
expect(exec).not.toContain("I keep momentum across stalled tasks.");
// Snapshot must precede the Wake Delta and the Heartbeat Procedure // Snapshot must precede the Wake Delta and the Heartbeat Procedure
const snapIdx = exec.indexOf("## Identity Snapshot"); const snapIdx = exec.indexOf("## Identity Snapshot");

View File

@@ -21,7 +21,8 @@ import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHea
import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core"; import { buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createIdentityTool, taskCreateParams } from "./agent-tools.js"; import { createHash } from "node:crypto";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js"; import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
import { heartbeatLog, formatError } from "./logger.js"; import { heartbeatLog, formatError } from "./logger.js";
@@ -295,9 +296,8 @@ export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in o
1. **Identity & context** — review the **Identity Snapshot** at the top of 1. **Identity & context** — review the **Identity Snapshot** at the top of
this prompt. Confirm your role, soul, instructions, and memory match what this prompt. Confirm your role, soul, instructions, and memory match what
you expect, and surface any anomalies in your first text output before you expect, and surface any anomalies in your first text output before
doing anything else. (If fn_identity is available in your runtime you may doing anything else. The full content is in the Custom Instructions
also call it for full structured detail; the snapshot above is the section of your system prompt.
authoritative source.)
2. **Inbox** — when fn_read_messages is available, call it. Process any pending 2. **Inbox** — when fn_read_messages is available, call it. Process any pending
messages first; reply with reply_to_message_id when answering. messages first; reply with reply_to_message_id when answering.
3. **Wake delta** — read the Wake Delta block above. The wake reason is the 3. **Wake delta** — read the Wake Delta block above. The wake reason is the
@@ -328,9 +328,8 @@ export const HEARTBEAT_NO_TASK_PROCEDURE = `## Heartbeat Procedure (run every ti
1. **Identity & context** — review the **Identity Snapshot** at the top of 1. **Identity & context** — review the **Identity Snapshot** at the top of
this prompt. Confirm your role, soul, instructions, and memory match what this prompt. Confirm your role, soul, instructions, and memory match what
you expect, and surface any anomalies in your first text output before you expect, and surface any anomalies in your first text output before
doing anything else. (If fn_identity is available in your runtime you may doing anything else. The full content is in the Custom Instructions
also call it for full structured detail; the snapshot above is the section of your system prompt.
authoritative source.)
2. **Inbox** — when fn_read_messages is available, call it. Process any pending 2. **Inbox** — when fn_read_messages is available, call it. Process any pending
messages first; reply with reply_to_message_id when answering. messages first; reply with reply_to_message_id when answering.
3. **Wake delta** — read the Wake Delta block above. The wake reason is the 3. **Wake delta** — read the Wake Delta block above. The wake reason is the
@@ -372,49 +371,43 @@ function truncatePrompt(text: string, maxChars: number): string {
* wrap external CLIs and may not propagate JS `customTools` callbacks to the * wrap external CLIs and may not propagate JS `customTools` callbacks to the
* underlying agent. Embedding the snapshot in the prompt body guarantees the * underlying agent. Embedding the snapshot in the prompt body guarantees the
* agent always sees its identity regardless of runtime tool support. * agent always sees its identity regardless of runtime tool support.
* `fn_identity` remains available as a richer optional read for runtimes *
* that DO support custom tools. * The full soul/instructions/memory content is already loaded in the system
* prompt's Custom Instructions section. The snapshot intentionally carries
* only presence flags + 8-char content hashes — enough to detect drift or
* misload, without paying a multi-KB preview tax on every tick.
*/ */
function shortContentHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 8);
}
function buildIdentitySnapshot(args: { function buildIdentitySnapshot(args: {
agent: Agent; agent: Agent;
resolvedInstructions: string; resolvedInstructions: string;
}): string { }): string {
const { agent, resolvedInstructions } = args; const { agent, resolvedInstructions } = args;
const SOUL_PREVIEW = 500;
const INSTR_PREVIEW = 1000;
const MEM_PREVIEW = 1000;
const soulPresent = typeof agent.soul === "string" && agent.soul.trim().length > 0; const soulTrimmed = typeof agent.soul === "string" ? agent.soul.trim() : "";
const instrPresent = resolvedInstructions.trim().length > 0; const instrTrimmed = resolvedInstructions.trim();
const memPresent = typeof agent.memory === "string" && agent.memory.trim().length > 0; const memTrimmed = typeof agent.memory === "string" ? agent.memory.trim() : "";
const lines: string[] = [ const formatField = (trimmed: string): string => {
if (!trimmed) return "absent";
return `loaded (${trimmed.length} chars, sha256:${shortContentHash(trimmed)})`;
};
return [
"## Identity Snapshot", "## Identity Snapshot",
"", "",
"Verify these match what you expect. Surface any anomalies in your first text output before acting.", "Full content is in the Custom Instructions section of your system prompt. Surface anomalies in your first text output before acting.",
"", "",
`- agentId: ${agent.id}`, `- agentId: ${agent.id}`,
`- name: ${agent.name}`, `- name: ${agent.name}`,
`- role: ${agent.role}`, `- role: ${agent.role}`,
`- soul: ${soulPresent ? "loaded" : "absent"}`, `- soul: ${formatField(soulTrimmed)}`,
`- instructions: ${instrPresent ? "loaded" : "absent"}`, `- instructions: ${formatField(instrTrimmed)}`,
`- memory: ${memPresent ? "loaded" : "absent"}`, `- memory: ${formatField(memTrimmed)}`,
]; ].join("\n");
if (soulPresent) {
const preview = (agent.soul as string).trim().slice(0, SOUL_PREVIEW);
lines.push("", `### Soul (first ${SOUL_PREVIEW} chars)`, preview);
}
if (instrPresent) {
const preview = resolvedInstructions.trim().slice(0, INSTR_PREVIEW);
lines.push("", `### Instructions (first ${INSTR_PREVIEW} chars)`, preview);
}
if (memPresent) {
const preview = (agent.memory as string).trim().slice(0, MEM_PREVIEW);
lines.push("", `### Memory (first ${MEM_PREVIEW} chars)`, preview);
}
return lines.join("\n");
} }
async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> { async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> {
@@ -1415,9 +1408,6 @@ export class HeartbeatMonitor {
[resolvedInstructionsForIdentity, memoryInstructions].filter((part) => part.trim()).join("\n\n"), [resolvedInstructionsForIdentity, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
); );
// Register fn_identity tool before fn_heartbeat_done (which must stay last)
heartbeatTools.push(createIdentityTool({ agent, resolvedInstructions: resolvedInstructionsForIdentity }));
// fn_heartbeat_done must be the last tool in the array (stable terminal signal) // fn_heartbeat_done must be the last tool in the array (stable terminal signal)
heartbeatTools.push(heartbeatDoneTool); heartbeatTools.push(heartbeatDoneTool);

View File

@@ -11,7 +11,7 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, Agent, TaskCreateInput } from "@fusion/core"; import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core"; import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js"; import { ResearchProviderRegistry } from "./research/provider-registry.js";
@@ -1359,78 +1359,3 @@ export function createReadMessagesTool(messageStore: MessageStore, agentId: stri
}; };
} }
/** Arguments for {@link createIdentityTool}. */
export interface CreateIdentityToolArgs {
/** The agent record for this heartbeat run. */
agent: Agent;
/** The resolved instructions string (from resolveAgentInstructionsWithRatings). */
resolvedInstructions: string;
}
/**
* Create the `fn_identity` tool for heartbeat sessions.
*
* When called, it returns a structured summary of which soul, instructions, and
* memory are currently loaded for this tick. The agent is expected to call this
* as its FIRST tool action so operators (via dashboard run logs) can verify
* correct identity was applied.
*/
export function createIdentityTool({ agent, resolvedInstructions }: CreateIdentityToolArgs): ToolDefinition {
const identityParams = Type.Object({});
return {
name: "fn_identity",
label: "Identity Check",
description: "Return a structured summary of which soul, instructions, and memory are loaded for this heartbeat tick. Call this FIRST before any other tool.",
parameters: identityParams,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: async (_id: string, _params: Static<typeof identityParams>, _signal?: any, _onUpdate?: any, _ctx?: any) => {
const PREVIEW_CHARS = 500;
const INSTRUCTIONS_PREVIEW_CHARS = 1000;
const MEMORY_PREVIEW_CHARS = 1000;
const soulPresent = typeof agent.soul === "string" && agent.soul.trim().length > 0;
const instructionsPresent = resolvedInstructions.trim().length > 0;
const memoryPresent = typeof agent.memory === "string" && agent.memory.trim().length > 0;
const soulPreview = soulPresent ? (agent.soul as string).slice(0, PREVIEW_CHARS) : "";
const instructionsPreview = instructionsPresent ? resolvedInstructions.slice(0, INSTRUCTIONS_PREVIEW_CHARS) : "";
const memoryPreview = memoryPresent ? (agent.memory as string).slice(0, MEMORY_PREVIEW_CHARS) : "";
const result = {
agentId: agent.id,
name: agent.name,
role: agent.role,
soulPresent,
instructionsPresent,
memoryPresent,
soulPreview,
instructionsPreview,
memoryPreview,
};
const lines = [
`agentId: ${result.agentId}`,
`name: ${result.name}`,
`role: ${result.role}`,
`soul: ${result.soulPresent ? "loaded" : "absent"}`,
`instructions: ${result.instructionsPresent ? "loaded" : "absent"}`,
`memory: ${result.memoryPresent ? "loaded" : "absent"}`,
];
if (result.soulPresent && result.soulPreview) {
lines.push(`\nSoul preview (first ${PREVIEW_CHARS} chars):\n${result.soulPreview}`);
}
if (result.instructionsPresent && result.instructionsPreview) {
lines.push(`\nInstructions preview (first ${INSTRUCTIONS_PREVIEW_CHARS} chars):\n${result.instructionsPreview}`);
}
if (result.memoryPresent && result.memoryPreview) {
lines.push(`\nMemory preview (first ${MEMORY_PREVIEW_CHARS} chars):\n${result.memoryPreview}`);
}
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: result,
};
},
};
}