feat(FN-2962): merge fusion/fn-2962
- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support Commits merged: - feat(FN-2962): complete Step 8 — add changeset and documentation Files changed: .changeset/register-custom-providers.md | 5 +++++ 1 file changed, 5 insertions(+) Fusion-Task-Id: FN-2962
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
} from "../agent-heartbeat.js";
|
||||
import { AgentLogger } from "../agent-logger.js";
|
||||
import * as agentTools from "../agent-tools.js";
|
||||
@@ -1655,6 +1656,10 @@ describe("HeartbeatMonitor", () => {
|
||||
// Should NOT include task-specific content
|
||||
expect(executionPrompt).not.toContain("Assigned task:");
|
||||
expect(executionPrompt).not.toContain("Task description:");
|
||||
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
expect(executionPrompt).toContain("wake reason:");
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
|
||||
@@ -2123,6 +2128,60 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(executionPrompt).toContain("Pending Messages:");
|
||||
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
|
||||
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
|
||||
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
|
||||
// the agent re-runs its procedure each tick instead of grinding on the
|
||||
// assigned task (paperclip-parity).
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
expect(executionPrompt).toContain("wake reason: message_received");
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
|
||||
try {
|
||||
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
|
||||
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
|
||||
|
||||
const store = createStoreWithAgentForExec({
|
||||
heartbeatProcedurePath: "MY-PROCEDURE.md",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
|
||||
// Custom procedure should appear; default constant should not.
|
||||
expect(executionPrompt).toContain("## Custom CEO Procedure");
|
||||
expect(executionPrompt).toContain("1. Review reports");
|
||||
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
|
||||
// Wake Delta still rendered.
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
|
||||
const store = createStoreWithAgentForExec({
|
||||
heartbeatProcedurePath: "../escape.md",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
// Invalid path → fall back to the default constant.
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("does not include message section when no unread messages", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const reloadMock = vi.fn(async () => {});
|
||||
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
||||
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
||||
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
||||
const readCustomProvidersMock = vi.fn(() => []);
|
||||
|
||||
// Route async `exec` through the `execSync` mock so the promisify bridge works.
|
||||
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
|
||||
@@ -68,6 +69,10 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../custom-providers.js", () => ({
|
||||
readCustomProviders: readCustomProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
@@ -382,6 +387,7 @@ describe("createFnAgent", () => {
|
||||
execSyncMock.mockReturnValue("");
|
||||
existsSyncMock.mockReturnValue(false);
|
||||
readFileSyncMock.mockReturnValue("{}");
|
||||
readCustomProvidersMock.mockReturnValue([]);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
createAgentSessionMock.mockResolvedValue({
|
||||
session: {
|
||||
@@ -484,6 +490,36 @@ describe("createFnAgent", () => {
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers custom providers from global settings", async () => {
|
||||
readCustomProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "custom-openai",
|
||||
name: "Custom OpenAI",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://custom.example/v1",
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
models: [{ id: "custom-model", name: "Custom Model" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(registerProviderMock).toHaveBeenCalledWith("custom-openai", expect.objectContaining({
|
||||
baseUrl: "https://custom.example/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
|
||||
}));
|
||||
});
|
||||
|
||||
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
|
||||
import { heartbeatLog, formatError } from "./logger.js";
|
||||
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
||||
import { promptWithFallback } from "./pi.js";
|
||||
@@ -285,6 +285,37 @@ When sending messages:
|
||||
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
|
||||
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
|
||||
|
||||
/**
|
||||
* Per-tick heartbeat procedure appended to every execution prompt. Forces the
|
||||
* agent to re-anchor on its own operating procedure each wake instead of
|
||||
* silently grinding on a previously assigned task.
|
||||
*/
|
||||
export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
|
||||
|
||||
1. **Identity & context** — review your soul, instructions, and memory (already
|
||||
loaded in the system prompt). Confirm who you are and what you're responsible
|
||||
for before continuing prior work.
|
||||
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
|
||||
messages first; reply with reply_to_message_id when answering.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
4. **Assignment review** — if you have an assigned task, re-read its current
|
||||
description, latest comments, and any task documents. Decide whether the
|
||||
prior plan is still valid given the wake delta. Do not assume yesterday's
|
||||
plan is still correct.
|
||||
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
|
||||
advance the task, create a follow-up, log findings, delegate, or update
|
||||
memory. Don't stop at planning unless the task is a planning task.
|
||||
6. **Persist progress** — fn_task_log for observations, fn_task_document_write
|
||||
for durable findings, status updates only when the work warrants it.
|
||||
7. **Exit** — call fn_heartbeat_done with a one-line summary of what changed
|
||||
this tick. If you took no action, say so and explain why.
|
||||
|
||||
Critical: a heartbeat without observable progress (a log, a document write, a
|
||||
status change, a comment, a delegation, or an explicit "no-op with reason") is
|
||||
a bug. Do not loop on the same plan across heartbeats without recording why.`;
|
||||
|
||||
/** Parameter schema for the fn_heartbeat_done tool */
|
||||
const heartbeatDoneParams = Type.Object({
|
||||
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
|
||||
@@ -1337,6 +1368,30 @@ export class HeartbeatMonitor {
|
||||
let pendingMessages: Message[] = [];
|
||||
let executionPrompt: string;
|
||||
|
||||
// Derive a stable wake reason from source, triggerDetail, and trigger
|
||||
// type so the agent can change its strategy based on *why* it woke up.
|
||||
// Mirrors paperclip's PAPERCLIP_WAKE_REASON (see plan: wake delta).
|
||||
const deriveWakeReason = (): string => {
|
||||
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
|
||||
if (triggerDetail === "wake-on-message") return "message_received";
|
||||
if (triggerDetail === "wake-on-comment") return "comment_mention";
|
||||
if (triggerDetail === "task-assigned") return "task_assigned";
|
||||
if (source === "timer") return "timer";
|
||||
if (source === "assignment") return "task_assigned";
|
||||
if (source === "automation") return "automation";
|
||||
if (source === "routine") return "routine";
|
||||
return triggerDetail || source;
|
||||
};
|
||||
const wakeReason = deriveWakeReason();
|
||||
|
||||
// Per-agent override of the default HEARTBEAT_PROCEDURE: if the agent
|
||||
// configured a heartbeatProcedurePath pointing to a markdown file in
|
||||
// the project, use that instead. Reloaded fresh each tick (matches the
|
||||
// existing instructionsPath/instructionsText reload contract) so an
|
||||
// operator can iterate on procedure text without restarting agents.
|
||||
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
|
||||
const heartbeatProcedureText = customProcedure ?? HEARTBEAT_PROCEDURE;
|
||||
|
||||
if (isNoTaskRun) {
|
||||
// No-task heartbeat: agent has identity but no assigned task
|
||||
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||
@@ -1365,6 +1420,16 @@ export class HeartbeatMonitor {
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
"",
|
||||
"## Wake Delta",
|
||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`- wake reason: ${wakeReason}`,
|
||||
`- assigned task: none`,
|
||||
`- pending messages: ${pendingMessages.length}`,
|
||||
"",
|
||||
"Treat this wake delta as the highest-priority change for this heartbeat.",
|
||||
"Run the Heartbeat Procedure (below) before doing anything else — even a",
|
||||
"timer-only wake should re-check messages, memory, and project state.",
|
||||
"",
|
||||
"**No assigned task** — This heartbeat run has no task assignment.",
|
||||
"",
|
||||
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
|
||||
@@ -1388,6 +1453,9 @@ export class HeartbeatMonitor {
|
||||
"",
|
||||
"Your soul, instructions, and memory are already loaded in the system prompt.",
|
||||
"Focus on work that benefits the project without requiring a specific task context.",
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
} else {
|
||||
@@ -1450,6 +1518,18 @@ export class HeartbeatMonitor {
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`Assigned task: ${taskId} — ${taskTitle}`,
|
||||
"",
|
||||
"## Wake Delta",
|
||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`- wake reason: ${wakeReason}`,
|
||||
`- assigned task: ${taskId}`,
|
||||
`- pending messages: ${pendingMessages.length}`,
|
||||
`- triggering comments: ${effectiveTriggeringCommentIds?.length ?? 0}`,
|
||||
"",
|
||||
"Treat this wake delta as the highest-priority change for this heartbeat.",
|
||||
"Before resuming prior task work, run the Heartbeat Procedure (below) and",
|
||||
"decide what action this delta requires. Your assigned task is one input",
|
||||
"to the procedure — not the only thing to consider.",
|
||||
"",
|
||||
"Task description:",
|
||||
taskDetail!.description,
|
||||
"",
|
||||
@@ -1457,7 +1537,9 @@ export class HeartbeatMonitor {
|
||||
...triggeringCommentLines,
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
"Review the task status and take appropriate action. Call fn_heartbeat_done when finished.",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
|
||||
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
|
||||
import {
|
||||
readProjectMemory,
|
||||
type Agent,
|
||||
@@ -32,7 +33,12 @@ function isPathTraversal(path: string): boolean {
|
||||
return path.split(/[\\/]+/).includes("..");
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
function resolveValidatedMarkdownPath(
|
||||
rawPath: string,
|
||||
rootDir: string,
|
||||
agentId: string,
|
||||
fieldLabel: string,
|
||||
): string | null {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -40,37 +46,118 @@ function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agen
|
||||
|
||||
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
|
||||
log.warn(
|
||||
`instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
`${fieldLabel} too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trimmed.toLowerCase().endsWith(".md")) {
|
||||
log.warn(`instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolute(trimmed)) {
|
||||
log.warn(`instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalize(trimmed);
|
||||
if (isPathTraversal(normalized)) {
|
||||
log.warn(`instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(rootDir, normalized);
|
||||
const rel = relative(rootDir, resolvedPath);
|
||||
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
||||
log.warn(`instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
return resolveValidatedMarkdownPath(rawPath, rootDir, agentId, "instructionsPath");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a per-agent heartbeat procedure markdown file. Returns the file
|
||||
* contents (trimmed/clamped to MAX_INSTRUCTIONS_TEXT_LENGTH), or null if no
|
||||
* path is configured, the path is invalid, or the file is unreadable. Caller
|
||||
* substitutes a default constant on null.
|
||||
*/
|
||||
export async function resolveAgentHeartbeatProcedure(
|
||||
agent: Agent | null | undefined,
|
||||
rootDir: string,
|
||||
): Promise<string | null> {
|
||||
const rawPath = agent?.heartbeatProcedurePath?.trim();
|
||||
if (!agent || !rawPath) {
|
||||
return null;
|
||||
}
|
||||
const filePath = resolveValidatedMarkdownPath(rawPath, rootDir, agent.id, "heartbeatProcedurePath");
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const normalized = trimAndClamp(
|
||||
content,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"heartbeat procedure file content",
|
||||
agent.id,
|
||||
);
|
||||
return normalized || null;
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
log.warn(`Heartbeat procedure file not found for agent ${agent.id}: ${filePath}`);
|
||||
} else {
|
||||
log.warn(`Failed to read heartbeat procedure file for agent ${agent.id}: ${filePath} (${code})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the default heartbeat procedure file at `pathRel` (project-relative)
|
||||
* if it doesn't already exist. Idempotent: leaves an existing file untouched
|
||||
* so operators can edit the procedure without losing their changes on the
|
||||
* next upgrade run. Returns the absolute path that was ensured (or null if
|
||||
* the path is invalid).
|
||||
*
|
||||
* The default content is supplied by the caller — kept as a parameter rather
|
||||
* than imported from agent-heartbeat.ts to avoid a circular dependency
|
||||
* (agent-heartbeat.ts already imports from this module).
|
||||
*/
|
||||
export async function ensureDefaultHeartbeatProcedureFile(
|
||||
rootDir: string,
|
||||
procedurePathRel: string,
|
||||
defaultContent: string,
|
||||
): Promise<string | null> {
|
||||
// Reuse the same path validation as the per-agent loader so we never write
|
||||
// outside the project root.
|
||||
const filePath = resolveValidatedMarkdownPath(procedurePathRel, rootDir, "system", "heartbeatProcedurePath");
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await access(filePath, fsConstants.F_OK);
|
||||
return filePath; // Already exists — preserve operator edits.
|
||||
} catch {
|
||||
// Falls through to write below.
|
||||
}
|
||||
try {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, defaultContent, "utf-8");
|
||||
log.log(`Seeded default heartbeat procedure file at ${filePath}`);
|
||||
return filePath;
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to seed default heartbeat procedure file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrendLabel(trend: AgentRatingSummary["trend"]): string {
|
||||
switch (trend) {
|
||||
case "improving":
|
||||
|
||||
15
packages/engine/src/custom-providers.ts
Normal file
15
packages/engine/src/custom-providers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
|
||||
export function readCustomProviders(): CustomProvider[] {
|
||||
try {
|
||||
const settingsPath = join(homedir(), ".fusion", "settings.json");
|
||||
const raw = readFileSync(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
||||
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ export {
|
||||
resolveAgentInstructionsWithRatings,
|
||||
resolveAgentInstructions,
|
||||
buildSystemPromptWithInstructions,
|
||||
resolveAgentHeartbeatProcedure,
|
||||
ensureDefaultHeartbeatProcedureFile,
|
||||
} from "./agent-instructions.js";
|
||||
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||
import { piLog, extensionsLog } from "./logger.js";
|
||||
import { readCustomProviders } from "./custom-providers.js";
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
@@ -998,6 +999,35 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||
await registerExtensionProviders(options.cwd, modelRegistry);
|
||||
|
||||
for (const provider of readCustomProviders()) {
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: false,
|
||||
input: ["text" as const],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
})),
|
||||
});
|
||||
piLog.log(`Registered custom provider ${provider.id}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
piLog.warn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
modelRegistry.refresh();
|
||||
|
||||
// Build the pi built-in tool set. We deliberately do NOT use the bundled
|
||||
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
|
||||
// tools that pi-claude-cli's Claude→pi name mapping depends on (Glob→find,
|
||||
|
||||
Reference in New Issue
Block a user