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:
5
.changeset/fn-3059-provider-surface-cleanup.md
Normal file
5
.changeset/fn-3059-provider-surface-cleanup.md
Normal 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.
|
||||||
5
.changeset/trim-heartbeat-identity-redundancy.md
Normal file
5
.changeset/trim-heartbeat-identity-redundancy.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
|||||||
17
packages/dashboard/app/components/providerVisibility.ts
Normal file
17
packages/dashboard/app/components/providerVisibility.ts
Normal 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 };
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user