feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
@@ -68,6 +68,8 @@ export interface HeartbeatMonitorOptions {
|
||||
/** Project root directory for agent session CWD.
|
||||
* When not provided, executeHeartbeat() will throw. */
|
||||
rootDir?: string;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
}
|
||||
|
||||
/** Options for waking up an agent */
|
||||
@@ -240,6 +242,7 @@ export class HeartbeatMonitor {
|
||||
private taskStore?: TaskStore;
|
||||
private rootDir?: string;
|
||||
private messageStore?: MessageStore;
|
||||
private pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||
@@ -263,6 +266,14 @@ export class HeartbeatMonitor {
|
||||
this.taskStore = options.taskStore;
|
||||
this.rootDir = options.rootDir;
|
||||
this.messageStore = options.messageStore;
|
||||
this.pluginRunner = options.pluginRunner;
|
||||
this.onRecovered = options.onRecovered;
|
||||
this.onTerminated = options.onTerminated;
|
||||
this.onRunStarted = options.onRunStarted;
|
||||
this.onRunCompleted = options.onRunCompleted;
|
||||
this.taskStore = options.taskStore;
|
||||
this.rootDir = options.rootDir;
|
||||
this.messageStore = options.messageStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1060,6 +1071,7 @@ export class HeartbeatMonitor {
|
||||
|
||||
// Lazy-load createFnAgent and promptWithFallback
|
||||
const { createFnAgent, promptWithFallback } = await import("./pi.js");
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
const { buildSessionSkillContextSync } = await import("./session-skill-context.js");
|
||||
|
||||
// Build tools with task creation tracking and run context for mutation correlation
|
||||
@@ -1135,7 +1147,9 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
// Create agent session
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "heartbeat",
|
||||
pluginRunner: this.pluginRunner,
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
|
||||
116
packages/engine/src/agent-runtime.ts
Normal file
116
packages/engine/src/agent-runtime.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Agent runtime adapter abstraction layer.
|
||||
*
|
||||
* Provides a typed interface for creating and managing agent sessions
|
||||
* across different runtime implementations (default pi runtime, plugin-provided runtimes).
|
||||
*
|
||||
* ## Interface Contract
|
||||
*
|
||||
* All runtimes must implement:
|
||||
* - `id`: Unique runtime identifier (e.g., "pi", "code-interpreter")
|
||||
* - `name`: Human-readable name
|
||||
* - `createSession()`: Create a new agent session
|
||||
* - `promptWithFallback()`: Prompt with automatic retry/compaction
|
||||
* - `describeModel()`: Get model description from session
|
||||
*/
|
||||
|
||||
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
|
||||
/**
|
||||
* Options for creating an agent session.
|
||||
* Mirrors the options accepted by createFnAgent.
|
||||
*/
|
||||
export interface AgentRuntimeOptions {
|
||||
/** Working directory for the agent session */
|
||||
cwd: string;
|
||||
/** System prompt for the agent */
|
||||
systemPrompt: string;
|
||||
/** Tool set to use: "coding" for full tools, "readonly" for read-only access */
|
||||
tools?: "coding" | "readonly";
|
||||
/** Additional custom tools to merge with the base toolset */
|
||||
customTools?: ToolDefinition[];
|
||||
/** Callback for text output from the agent */
|
||||
onText?: (delta: string) => void;
|
||||
/** Callback for thinking/thought output from the agent */
|
||||
onThinking?: (delta: string) => void;
|
||||
/** Callback when a tool starts execution */
|
||||
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
|
||||
/** Callback when a tool finishes execution */
|
||||
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
|
||||
/** Default model provider (e.g. "anthropic") */
|
||||
defaultProvider?: string;
|
||||
/** Default model ID within the provider (e.g. "claude-sonnet-4-5") */
|
||||
defaultModelId?: string;
|
||||
/** Optional fallback model provider for retryable errors */
|
||||
fallbackProvider?: string;
|
||||
/** Optional fallback model ID */
|
||||
fallbackModelId?: string;
|
||||
/** Default thinking effort level (e.g. "medium", "high") */
|
||||
defaultThinkingLevel?: string;
|
||||
/** Optional pre-configured SessionManager for persistence */
|
||||
sessionManager?: SessionManager;
|
||||
/** Optional skill selection context */
|
||||
skillSelection?: SkillSelectionContext;
|
||||
/** Convenience: skill names to include in the session */
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating an agent session.
|
||||
*/
|
||||
export interface AgentSessionResult {
|
||||
/** The created agent session */
|
||||
session: AgentSession;
|
||||
/** Path to the persisted session file (undefined for in-memory sessions) */
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent runtime adapter interface.
|
||||
*
|
||||
* All session runtimes (default pi runtime, plugin-provided runtimes) must
|
||||
* implement this interface to ensure consistent behavior across engine subsystems.
|
||||
*
|
||||
* ## Implementation Notes
|
||||
*
|
||||
* - `createSession()` should return a fully initialized session ready for prompting
|
||||
* - `promptWithFallback()` should handle retry/compaction automatically
|
||||
* - `describeModel()` should return a human-readable model identifier
|
||||
*/
|
||||
export interface AgentRuntime {
|
||||
/** Unique runtime identifier (e.g., "pi", "code-interpreter", "web-search") */
|
||||
readonly id: string;
|
||||
/** Human-readable name for the runtime */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Create a new agent session.
|
||||
*
|
||||
* @param options - Session creation options
|
||||
* @returns Promise resolving to the session result
|
||||
*/
|
||||
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||
|
||||
/**
|
||||
* Prompt the session with user input.
|
||||
*
|
||||
* Implementations should handle:
|
||||
* - Automatic retry on transient errors
|
||||
* - Context compaction on context limit errors
|
||||
* - Model fallback on retryable model selection errors
|
||||
*
|
||||
* @param session - The session to prompt
|
||||
* @param prompt - The prompt text
|
||||
* @param options - Optional prompt options (e.g., images for vision)
|
||||
*/
|
||||
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a human-readable model description from a session.
|
||||
*
|
||||
* @param session - The session to describe
|
||||
* @returns Model description (e.g., "anthropic/claude-sonnet-4-5") or "unknown model"
|
||||
*/
|
||||
describeModel(session: AgentSession): string;
|
||||
}
|
||||
111
packages/engine/src/agent-session-helpers.ts
Normal file
111
packages/engine/src/agent-session-helpers.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Helper functions for creating agent sessions with runtime resolution.
|
||||
*
|
||||
* These helpers wrap the runtime resolution pattern so that subsystems
|
||||
* don't need to duplicate the resolution logic. They use the resolver
|
||||
* to select the appropriate runtime and then delegate to it for session
|
||||
* creation and prompting.
|
||||
*/
|
||||
|
||||
import type { AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/** Logger for agent session helpers */
|
||||
const sessionLog = createLogger("agent-session");
|
||||
|
||||
/**
|
||||
* Options for creating an agent session with runtime resolution.
|
||||
*/
|
||||
export interface ResolvedSessionOptions extends AgentRuntimeOptions {
|
||||
/** Session purpose for runtime selection */
|
||||
sessionPurpose: SessionPurpose;
|
||||
/** Plugin runner for runtime lookup. When provided, enables plugin runtime selection. */
|
||||
pluginRunner?: PluginRunner;
|
||||
/** Optional runtime hint from task/agent configuration */
|
||||
runtimeHint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating an agent session with runtime resolution.
|
||||
*/
|
||||
export interface ResolvedSessionResult {
|
||||
/** The created agent session */
|
||||
session: AgentSession;
|
||||
/** Path to the persisted session file (undefined for in-memory sessions) */
|
||||
sessionFile?: string;
|
||||
/** The runtime ID that was used */
|
||||
runtimeId: string;
|
||||
/** Whether the runtime was explicitly configured */
|
||||
wasConfigured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent session using runtime resolution.
|
||||
*
|
||||
* This function:
|
||||
* 1. Resolves the appropriate runtime based on sessionPurpose, runtimeHint, and pluginRunner
|
||||
* 2. Creates the session using the resolved runtime
|
||||
* 3. Returns the session along with metadata about which runtime was used
|
||||
*
|
||||
* @param options - Session creation options including purpose and runtime configuration
|
||||
* @returns Promise resolving to the session result with runtime metadata
|
||||
*/
|
||||
export async function createResolvedAgentSession(
|
||||
options: ResolvedSessionOptions,
|
||||
): Promise<ResolvedSessionResult> {
|
||||
const { sessionPurpose, pluginRunner, runtimeHint, ...runtimeOptions } = options;
|
||||
|
||||
// Build the resolution context
|
||||
const context = buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint);
|
||||
|
||||
// Resolve the runtime
|
||||
const resolved = await resolveRuntime(context);
|
||||
|
||||
sessionLog.log(
|
||||
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
|
||||
);
|
||||
|
||||
// Create the session using the resolved runtime
|
||||
const result = await resolved.runtime.createSession(runtimeOptions);
|
||||
|
||||
return {
|
||||
session: result.session,
|
||||
sessionFile: result.sessionFile,
|
||||
runtimeId: resolved.runtimeId,
|
||||
wasConfigured: resolved.wasConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt an agent session with automatic retry and compaction.
|
||||
*
|
||||
* This is a convenience wrapper that delegates to the runtime's promptWithFallback.
|
||||
*
|
||||
* @param session - The session to prompt
|
||||
* @param prompt - The prompt text
|
||||
* @param options - Optional prompt options (e.g., images)
|
||||
*/
|
||||
export async function promptWithAutoRetry(
|
||||
session: AgentSession,
|
||||
prompt: string,
|
||||
options?: unknown,
|
||||
): Promise<void> {
|
||||
// Dynamic import to get the default runtime's promptWithFallback
|
||||
// This works because the default runtime delegates to the existing implementation
|
||||
const { promptWithFallback: pwf } = await import("./pi.js");
|
||||
return pwf(session, prompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable model description from a session.
|
||||
*
|
||||
* @param session - The session to describe
|
||||
* @returns Model description string
|
||||
*/
|
||||
export async function describeAgentModel(session: AgentSession): Promise<string> {
|
||||
const { describeModel: dm } = await import("./pi.js");
|
||||
return dm(session);
|
||||
}
|
||||
@@ -49,6 +49,20 @@ vi.mock("./merger.js", () => ({
|
||||
aiMergeTask: vi.fn(),
|
||||
findWorktreeUser: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("./agent-session-helpers.js", async () => {
|
||||
const { createFnAgent } = await import("./pi.js");
|
||||
return {
|
||||
createResolvedAgentSession: async (options: any) => {
|
||||
const result = await createFnAgent(options);
|
||||
return {
|
||||
session: result.session,
|
||||
sessionFile: result.sessionFile,
|
||||
runtimeId: "pi",
|
||||
wasConfigured: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("./worktree-names.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
|
||||
return {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentProm
|
||||
import { findWorktreeUser } from "./merger.js";
|
||||
import { generateWorktreeName, slugify } from "./worktree-names.js";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createFnAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { createResolvedAgentSession, describeAgentModel } from "./agent-session-helpers.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
@@ -1765,7 +1766,9 @@ export class TaskExecutor {
|
||||
|
||||
// sessionFile must be let because it's destructured alongside session which is reassigned
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { session, sessionFile } = await createFnAgent({
|
||||
let { session, sessionFile } = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: worktreePath,
|
||||
systemPrompt: executorSystemPrompt,
|
||||
tools: "coding",
|
||||
@@ -2000,7 +2003,9 @@ export class TaskExecutor {
|
||||
this.activeSessions.delete(task.id);
|
||||
session.dispose();
|
||||
|
||||
const { session: retrySession, sessionFile: retrySessionFile } = await createFnAgent({
|
||||
const { session: retrySession, sessionFile: retrySessionFile } = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: worktreePath,
|
||||
systemPrompt: executorSystemPrompt,
|
||||
tools: "coding",
|
||||
@@ -3627,7 +3632,9 @@ and show an appropriate message to the user.\`
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: worktreePath,
|
||||
systemPrompt: stepSystemPrompt,
|
||||
tools: toolMode,
|
||||
@@ -4673,7 +4680,9 @@ and show an appropriate message to the user.\`
|
||||
});
|
||||
|
||||
// Create child agent session
|
||||
const { session: childSession } = await createFnAgent({
|
||||
const { session: childSession } = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: childWorktreePath,
|
||||
systemPrompt: childSystemPrompt,
|
||||
tools: "coding",
|
||||
|
||||
@@ -47,6 +47,24 @@ export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from ".
|
||||
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
|
||||
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
|
||||
export { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js";
|
||||
// Agent runtime abstraction
|
||||
export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js";
|
||||
export {
|
||||
resolveRuntime,
|
||||
getDefaultPiRuntime,
|
||||
buildRuntimeResolutionContext,
|
||||
type RuntimeResolutionContext,
|
||||
type ResolvedRuntime,
|
||||
type SessionPurpose,
|
||||
} from "./runtime-resolution.js";
|
||||
// Agent session helpers
|
||||
export {
|
||||
createResolvedAgentSession,
|
||||
promptWithAutoRetry,
|
||||
describeAgentModel,
|
||||
type ResolvedSessionOptions,
|
||||
type ResolvedSessionResult,
|
||||
} from "./agent-session-helpers.js";
|
||||
export { ProjectManager } from "./project-manager.js";
|
||||
export { ProjectEngine, type ProjectEngineOptions } from "./project-engine.js";
|
||||
export { ProjectEngineManager, type EngineManagerOptions } from "./project-engine-manager.js";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
|
||||
import { resolveAgentPrompt } from "@fusion/core";
|
||||
import { createFnAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||||
import { createResolvedAgentSession } from "./agent-session-helpers.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
@@ -662,7 +663,9 @@ async function attemptInMergeVerificationFix(
|
||||
}
|
||||
|
||||
// Create the fix agent session
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
pluginRunner: options.pluginRunner,
|
||||
cwd: rootDir, // Runs on the main branch in the project root
|
||||
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
|
||||
|
||||
@@ -1332,6 +1335,8 @@ export interface MergerOptions {
|
||||
onSession?: (session: { dispose: () => void }) => void;
|
||||
/** AgentStore for resolving per-agent custom instructions. */
|
||||
agentStore?: import("@fusion/core").AgentStore;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
}
|
||||
|
||||
function quoteArg(value: string): string {
|
||||
@@ -1395,7 +1400,7 @@ async function resolveComplexRebaseConflictsWithAi(
|
||||
taskId: string,
|
||||
settings: Settings,
|
||||
conflictedFiles: string[],
|
||||
options?: { onAgentText?: (delta: string) => void },
|
||||
options?: { onAgentText?: (delta: string) => void; pluginRunner?: import("./plugin-runner.js").PluginRunner },
|
||||
): Promise<void> {
|
||||
mergerLog.log(`${taskId}: resolving ${conflictedFiles.length} complex rebase conflict(s) with AI`);
|
||||
|
||||
@@ -1420,7 +1425,9 @@ You are assisting with a paused \`git pull --rebase\`.
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
pluginRunner: options?.pluginRunner,
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "coding",
|
||||
@@ -2765,7 +2772,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
}
|
||||
}
|
||||
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
pluginRunner: options.pluginRunner,
|
||||
cwd: rootDir,
|
||||
systemPrompt: mergerSystemPrompt,
|
||||
tools: "coding",
|
||||
@@ -3243,7 +3252,9 @@ If issues are found that need attention, describe them clearly.`;
|
||||
}
|
||||
}
|
||||
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
pluginRunner: mergeOptions.pluginRunner,
|
||||
cwd,
|
||||
systemPrompt: postMergeSystemPrompt,
|
||||
tools: toolMode,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
MissionValidatorRun,
|
||||
} from "@fusion/core";
|
||||
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
|
||||
import { createResolvedAgentSession } from "./agent-session-helpers.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/** Logger for the mission execution loop subsystem. */
|
||||
@@ -63,6 +64,8 @@ export interface MissionExecutionLoopOptions {
|
||||
rootDir: string;
|
||||
/** Maximum implementation retry budget (default: 3) */
|
||||
maxRetryBudget?: number;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
}
|
||||
|
||||
export class MissionExecutionLoop extends EventEmitter {
|
||||
@@ -72,6 +75,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
private rootDir: string;
|
||||
private maxRetryBudget: number;
|
||||
private missionAutopilot?: MissionExecutionLoopOptions["missionAutopilot"];
|
||||
private pluginRunner?: MissionExecutionLoopOptions["pluginRunner"];
|
||||
private activeValidations = new Set<string>(); // feature IDs currently being validated
|
||||
|
||||
constructor(options: MissionExecutionLoopOptions) {
|
||||
@@ -81,6 +85,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
this.rootDir = options.rootDir;
|
||||
this.maxRetryBudget = options.maxRetryBudget ?? 3;
|
||||
this.missionAutopilot = options.missionAutopilot;
|
||||
this.pluginRunner = options.pluginRunner;
|
||||
loopLog.log("MissionExecutionLoop created");
|
||||
}
|
||||
|
||||
@@ -325,7 +330,9 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
|
||||
try {
|
||||
// Create validation agent session
|
||||
session = await createFnAgent({
|
||||
const sessionResult = await createResolvedAgentSession({
|
||||
sessionPurpose: "validation",
|
||||
pluginRunner: this.pluginRunner,
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext),
|
||||
tools: "readonly",
|
||||
@@ -334,6 +341,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
// Could stream this to a log entry if needed
|
||||
},
|
||||
});
|
||||
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };
|
||||
|
||||
loopLog.log(`Validation session created for feature ${feature.id}`);
|
||||
|
||||
|
||||
@@ -738,6 +738,90 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeById()", () => {
|
||||
it("should return undefined when no runtimes exist", () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
const result = pluginRunner.getRuntimeById("code-interpreter");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the runtime when runtimeId matches", () => {
|
||||
const mockRuntime = {
|
||||
metadata: {
|
||||
runtimeId: "code-interpreter",
|
||||
name: "Code Interpreter",
|
||||
},
|
||||
factory: vi.fn(),
|
||||
};
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||
{ pluginId: "code-plugin", runtime: mockRuntime as any },
|
||||
]);
|
||||
|
||||
const result = pluginRunner.getRuntimeById("code-interpreter");
|
||||
expect(result).toEqual({ pluginId: "code-plugin", runtime: mockRuntime });
|
||||
});
|
||||
|
||||
it("should return undefined when runtimeId does not match", () => {
|
||||
const mockRuntime = {
|
||||
metadata: {
|
||||
runtimeId: "web-search",
|
||||
name: "Web Search",
|
||||
},
|
||||
factory: vi.fn(),
|
||||
};
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||
{ pluginId: "search-plugin", runtime: mockRuntime as any },
|
||||
]);
|
||||
|
||||
const result = pluginRunner.getRuntimeById("code-interpreter");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return first matching runtime when multiple plugins have same runtimeId", () => {
|
||||
const mockRuntime1 = {
|
||||
metadata: {
|
||||
runtimeId: "shared-id",
|
||||
name: "First Runtime",
|
||||
},
|
||||
factory: vi.fn(),
|
||||
};
|
||||
const mockRuntime2 = {
|
||||
metadata: {
|
||||
runtimeId: "shared-id",
|
||||
name: "Second Runtime",
|
||||
},
|
||||
factory: vi.fn(),
|
||||
};
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||
{ pluginId: "plugin-1", runtime: mockRuntime1 as any },
|
||||
{ pluginId: "plugin-2", runtime: mockRuntime2 as any },
|
||||
]);
|
||||
|
||||
const result = pluginRunner.getRuntimeById("shared-id");
|
||||
expect(result?.pluginId).toBe("plugin-1");
|
||||
});
|
||||
|
||||
it("should find runtime even when cache is already built", () => {
|
||||
const mockRuntime = {
|
||||
metadata: {
|
||||
runtimeId: "cached-runtime",
|
||||
name: "Cached Runtime",
|
||||
},
|
||||
factory: vi.fn(),
|
||||
};
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||
{ pluginId: "cached-plugin", runtime: mockRuntime as any },
|
||||
]);
|
||||
|
||||
// First call builds cache
|
||||
pluginRunner.getRuntimeById("other-id");
|
||||
// Second call should find from cache
|
||||
const result = pluginRunner.getRuntimeById("cached-runtime");
|
||||
expect(result?.pluginId).toBe("cached-plugin");
|
||||
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoader() / getStore()", () => {
|
||||
it("should return the plugin loader", () => {
|
||||
const loader = pluginRunner.getLoader();
|
||||
|
||||
@@ -234,6 +234,17 @@ export class PluginRunner {
|
||||
return this.cachedRuntimes.runtimes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific runtime registration by its runtimeId.
|
||||
*
|
||||
* @param runtimeId - The unique runtime identifier to find
|
||||
* @returns The runtime registration with plugin ID, or undefined if not found
|
||||
*/
|
||||
getRuntimeById(runtimeId: string): { pluginId: string; runtime: PluginRuntimeRegistration } | undefined {
|
||||
const registrations = this.getPluginRuntimes();
|
||||
return registrations.find((reg) => reg.runtime.metadata.runtimeId === runtimeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying plugin loader.
|
||||
*/
|
||||
@@ -481,6 +492,35 @@ export class PluginRunner {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plugin context for runtime instantiation.
|
||||
*
|
||||
* This context is passed to plugin runtime factories to allow them
|
||||
* to initialize their runtime instances with access to task store,
|
||||
* settings, and logging.
|
||||
*
|
||||
* @param pluginId - The plugin ID to create context for
|
||||
* @returns The plugin context, or null if the plugin is not loaded
|
||||
*/
|
||||
async createRuntimeContext(pluginId: string): Promise<PluginContext | null> {
|
||||
const plugin = this.options.pluginLoader.getPlugin(pluginId);
|
||||
if (!plugin) {
|
||||
this.log.warn(`Plugin "${pluginId}" not loaded, cannot create runtime context`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = await this.getPluginSettings(pluginId);
|
||||
return {
|
||||
pluginId,
|
||||
taskStore: this.options.taskStore,
|
||||
settings,
|
||||
logger: this.createPluginLogger(pluginId),
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.log.log(`[plugin:${pluginId}] Event: ${event}`, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings for a plugin from the store.
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,20 @@ vi.mock("./pi.js", () => ({
|
||||
vi.mock("./reviewer.js", () => ({
|
||||
reviewStep: vi.fn(),
|
||||
}));
|
||||
vi.mock("./agent-session-helpers.js", async () => {
|
||||
const { createFnAgent } = await import("./pi.js");
|
||||
return {
|
||||
createResolvedAgentSession: async (options: any) => {
|
||||
const result = await createFnAgent(options);
|
||||
return {
|
||||
session: result.session,
|
||||
sessionFile: result.sessionFile,
|
||||
runtimeId: "pi",
|
||||
wasConfigured: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("node:child_process", () => {
|
||||
const { promisify } = require("node:util");
|
||||
const execSyncFn = vi.fn().mockReturnValue(Buffer.from(""));
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
|
||||
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
|
||||
import { createFnAgent, describeModel, promptWithFallback } from "./pi.js";
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { createResolvedAgentSession } from "./agent-session-helpers.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { reviewerLog } from "./logger.js";
|
||||
@@ -230,6 +231,8 @@ export interface ReviewOptions {
|
||||
rootDir?: string;
|
||||
/** Project settings used for backend-aware memory tools and instructions. */
|
||||
settings?: Settings;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +362,9 @@ export async function reviewStep(
|
||||
} : undefined),
|
||||
]
|
||||
: undefined;
|
||||
const { session } = await createFnAgent({
|
||||
const { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "reviewer",
|
||||
pluginRunner: options.pluginRunner,
|
||||
cwd,
|
||||
systemPrompt: reviewerSystemPrompt,
|
||||
tools: "readonly",
|
||||
|
||||
332
packages/engine/src/runtime-resolution.test.ts
Normal file
332
packages/engine/src/runtime-resolution.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Runtime Resolution Tests
|
||||
*
|
||||
* Tests for the runtime resolution system including:
|
||||
* - Default pi runtime selection
|
||||
* - Plugin runtime lookup by hint
|
||||
* - Fallback behavior when configured runtime is unavailable
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
|
||||
import {
|
||||
resolveRuntime,
|
||||
getDefaultPiRuntime,
|
||||
type RuntimeResolutionContext,
|
||||
type ResolvedRuntime,
|
||||
} from "./runtime-resolution.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import type { PluginRuntimeRegistration } from "@fusion/core";
|
||||
|
||||
// Mock the logger to suppress output during tests
|
||||
vi.mock("./logger.js", () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock pi.js to avoid actual session creation
|
||||
vi.mock("./pi.js", () => ({
|
||||
createFnAgent: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
sessionFile: undefined,
|
||||
}),
|
||||
promptWithFallback: vi.fn().mockResolvedValue(undefined),
|
||||
describeModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
|
||||
}));
|
||||
|
||||
describe("runtime-resolution", () => {
|
||||
let mockPluginRunner: {
|
||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||
getRuntimeById: ReturnType<typeof vi.fn>;
|
||||
createRuntimeContext: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const createMockPluginRuntime = (runtimeId: string, name: string): PluginRuntimeRegistration => ({
|
||||
metadata: {
|
||||
runtimeId,
|
||||
name,
|
||||
description: `Test runtime ${runtimeId}`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
factory: vi.fn().mockResolvedValue({
|
||||
id: runtimeId,
|
||||
name,
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
session: { model: { provider: "test", id: "test-model" } },
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: vi.fn().mockReturnValue("test/model"),
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPluginRunner = {
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getRuntimeById: vi.fn().mockReturnValue(undefined),
|
||||
createRuntimeContext: vi.fn().mockResolvedValue({
|
||||
pluginId: "test-plugin",
|
||||
taskStore: {},
|
||||
settings: {},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
emitEvent: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getDefaultPiRuntime()", () => {
|
||||
it("should return the same instance on multiple calls", () => {
|
||||
const runtime1 = getDefaultPiRuntime();
|
||||
const runtime2 = getDefaultPiRuntime();
|
||||
expect(runtime1).toBe(runtime2);
|
||||
});
|
||||
|
||||
it("should have pi as the runtime id", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
expect(runtime.id).toBe("pi");
|
||||
});
|
||||
|
||||
it("should have a human-readable name", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
expect(runtime.name).toBe("Default PI Runtime");
|
||||
});
|
||||
|
||||
it("should implement createSession", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
expect(typeof runtime.createSession).toBe("function");
|
||||
});
|
||||
|
||||
it("should implement promptWithFallback", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
expect(typeof runtime.promptWithFallback).toBe("function");
|
||||
});
|
||||
|
||||
it("should implement describeModel", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
expect(typeof runtime.describeModel).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntime()", () => {
|
||||
const createContext = (
|
||||
purpose: RuntimeResolutionContext["sessionPurpose"] = "executor",
|
||||
runtimeHint?: string,
|
||||
): RuntimeResolutionContext => ({
|
||||
sessionPurpose: purpose,
|
||||
runtimeHint,
|
||||
pluginRunner: mockPluginRunner as unknown as PluginRunner,
|
||||
});
|
||||
|
||||
describe("no runtime hint", () => {
|
||||
it("should return default pi runtime when hint is undefined", async () => {
|
||||
const context = createContext("executor", undefined);
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.runtime.id).toBe("pi");
|
||||
});
|
||||
|
||||
it("should return default pi runtime when hint is empty string", async () => {
|
||||
const context = createContext("executor", "");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should return default pi runtime when hint is whitespace only", async () => {
|
||||
const context = createContext("executor", " ");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should work for all session purposes without hint", async () => {
|
||||
const purposes: RuntimeResolutionContext["sessionPurpose"][] = [
|
||||
"executor",
|
||||
"triage",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"heartbeat",
|
||||
"validation",
|
||||
];
|
||||
|
||||
for (const purpose of purposes) {
|
||||
const context = createContext(purpose, undefined);
|
||||
const result = await resolveRuntime(context);
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit pi hint", () => {
|
||||
it("should return pi runtime when hint is 'pi'", async () => {
|
||||
const context = createContext("executor", "pi");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("should return pi runtime when hint is 'default'", async () => {
|
||||
const context = createContext("executor", "default");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin runtime lookup", () => {
|
||||
it("should return plugin runtime when hint matches existing runtime", async () => {
|
||||
const mockRuntime = createMockPluginRuntime("code-interpreter", "Code Interpreter Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "test-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "code-interpreter");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("code-interpreter");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
expect(result.runtime.id).toBe("code-interpreter");
|
||||
});
|
||||
|
||||
it("should create runtime context when resolving plugin runtime", async () => {
|
||||
const mockRuntime = createMockPluginRuntime("web-search", "Web Search Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "web-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "web-search");
|
||||
await resolveRuntime(context);
|
||||
|
||||
expect(mockPluginRunner.createRuntimeContext).toHaveBeenCalledWith("web-plugin");
|
||||
});
|
||||
|
||||
it("should call the plugin runtime factory", async () => {
|
||||
const mockRuntime = createMockPluginRuntime("custom", "Custom Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "custom-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "custom");
|
||||
await resolveRuntime(context);
|
||||
|
||||
expect(mockRuntime.factory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should find runtime by ID when multiple plugins have runtimes", async () => {
|
||||
const mockRuntime = createMockPluginRuntime("unique-id", "First Runtime");
|
||||
// getRuntimeById returns the first match
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "plugin-1",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "unique-id");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
// Should return the matching runtime
|
||||
expect(result.runtime.id).toBe("unique-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback behavior", () => {
|
||||
it("should fall back to pi when runtime hint references non-existent runtime", async () => {
|
||||
// getRuntimeById returns undefined for non-existent runtime
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue(undefined);
|
||||
|
||||
const context = createContext("executor", "non-existent");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to pi when runtime factory throws", async () => {
|
||||
const mockRuntime: PluginRuntimeRegistration = {
|
||||
metadata: {
|
||||
runtimeId: "broken",
|
||||
name: "Broken Runtime",
|
||||
},
|
||||
factory: vi.fn().mockRejectedValue(new Error("Factory failed")),
|
||||
};
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "broken-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "broken");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to pi when runtime factory returns null", async () => {
|
||||
const mockRuntime: PluginRuntimeRegistration = {
|
||||
metadata: {
|
||||
runtimeId: "null-return",
|
||||
name: "Null Return Runtime",
|
||||
},
|
||||
factory: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "null-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "null-return");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to pi when createRuntimeContext returns null", async () => {
|
||||
mockPluginRunner.createRuntimeContext.mockResolvedValue(null);
|
||||
const mockRuntime = createMockPluginRuntime("orphan", "Orphan Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
pluginId: "orphan-plugin",
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
|
||||
const context = createContext("executor", "orphan");
|
||||
const result = await resolveRuntime(context);
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRuntime interface compliance", () => {
|
||||
it("default pi runtime should implement all required interface methods", () => {
|
||||
const runtime = getDefaultPiRuntime();
|
||||
|
||||
// Check required properties
|
||||
expect(typeof runtime.id).toBe("string");
|
||||
expect(typeof runtime.name).toBe("string");
|
||||
|
||||
// Check required methods
|
||||
expect(typeof runtime.createSession).toBe("function");
|
||||
expect(typeof runtime.promptWithFallback).toBe("function");
|
||||
expect(typeof runtime.describeModel).toBe("function");
|
||||
});
|
||||
});
|
||||
});
|
||||
365
packages/engine/src/runtime-resolution.ts
Normal file
365
packages/engine/src/runtime-resolution.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Runtime resolution utilities for selecting and instantiating agent runtimes.
|
||||
*
|
||||
* Provides a resolution layer that:
|
||||
* 1. Looks up plugin-provided runtimes when a runtime hint is configured
|
||||
* 2. Falls back to the default pi runtime when no hint is provided or lookup fails
|
||||
* 3. Provides structured logging for debugging runtime selection decisions
|
||||
*/
|
||||
|
||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/** Logger for the runtime resolution subsystem */
|
||||
const runtimeLog = createLogger("runtime-resolver");
|
||||
|
||||
/**
|
||||
* Session purpose for runtime selection context.
|
||||
* Determines which runtime selection rules apply.
|
||||
*/
|
||||
export type SessionPurpose =
|
||||
| "executor"
|
||||
| "triage"
|
||||
| "reviewer"
|
||||
| "merger"
|
||||
| "heartbeat"
|
||||
| "validation";
|
||||
|
||||
/**
|
||||
* Context for runtime resolution.
|
||||
* Provides all information needed to select and configure a runtime.
|
||||
*/
|
||||
export interface RuntimeResolutionContext {
|
||||
/** Purpose of the session (affects runtime selection behavior) */
|
||||
sessionPurpose: SessionPurpose;
|
||||
/** Optional runtime hint (runtimeId) from task/agent configuration.
|
||||
* When provided and non-empty, the resolver attempts to find a matching
|
||||
* plugin runtime before falling back to the default. */
|
||||
runtimeHint?: string;
|
||||
/** PluginRunner for looking up plugin-provided runtimes */
|
||||
pluginRunner: PluginRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of runtime resolution.
|
||||
*/
|
||||
export interface ResolvedRuntime {
|
||||
/** The resolved runtime instance */
|
||||
runtime: AgentRuntime;
|
||||
/** Whether this runtime was explicitly configured via hint (vs. default) */
|
||||
wasConfigured: boolean;
|
||||
/** The runtime ID that was resolved */
|
||||
runtimeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason for fallback when configured runtime is unavailable.
|
||||
*/
|
||||
export type FallbackReason =
|
||||
/** Runtime hint was provided but no matching runtime was found */
|
||||
| "not_found"
|
||||
/** Runtime factory function threw an error during instantiation */
|
||||
| "factory_error"
|
||||
/** Runtime was found but failed to initialize */
|
||||
| "init_error";
|
||||
|
||||
/**
|
||||
* Default pi-based runtime implementation.
|
||||
*
|
||||
* This runtime wraps the existing createFnAgent + promptWithFallback
|
||||
* implementation without any behavior changes. It serves as:
|
||||
* 1. The default runtime when no runtime hint is configured
|
||||
* 2. The fallback runtime when a configured plugin runtime is unavailable
|
||||
*/
|
||||
export class DefaultPiRuntime implements AgentRuntime {
|
||||
readonly id = "pi";
|
||||
readonly name = "Default PI Runtime";
|
||||
|
||||
// Synchronous cached describeModel function
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private static describeModelFn: ((session: AgentSession) => string) | null = null;
|
||||
|
||||
/**
|
||||
* Create an agent session using the default pi implementation.
|
||||
*/
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const { createFnAgent } = await import("./pi.js");
|
||||
return createFnAgent(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt with automatic retry and compaction.
|
||||
* Delegates to the existing promptWithFallback implementation.
|
||||
*/
|
||||
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
const { promptWithFallback: pwf } = await import("./pi.js");
|
||||
return pwf(session, prompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model description from session.
|
||||
*/
|
||||
describeModel(session: AgentSession): string {
|
||||
if (!DefaultPiRuntime.describeModelFn) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { describeModel } = require("./pi.js");
|
||||
DefaultPiRuntime.describeModelFn = describeModel;
|
||||
}
|
||||
return DefaultPiRuntime.describeModelFn!(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of the default pi runtime.
|
||||
* Reused across all resolution requests.
|
||||
*/
|
||||
let defaultPiRuntimeInstance: DefaultPiRuntime | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton default pi runtime instance.
|
||||
*/
|
||||
export function getDefaultPiRuntime(): AgentRuntime {
|
||||
if (!defaultPiRuntimeInstance) {
|
||||
defaultPiRuntimeInstance = new DefaultPiRuntime();
|
||||
}
|
||||
return defaultPiRuntimeInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a plugin runtime by its runtimeId.
|
||||
*
|
||||
* @param pluginRunner - PluginRunner for looking up runtimes
|
||||
* @param runtimeId - The runtime ID to find
|
||||
* @returns The resolved runtime wrapper, or null if not found
|
||||
*/
|
||||
async function resolvePluginRuntime(
|
||||
pluginRunner: PluginRunner,
|
||||
runtimeId: string,
|
||||
): Promise<{ runtime: AgentRuntime; pluginId: string } | null> {
|
||||
// Use the convenience method for single runtime lookup
|
||||
const registration = pluginRunner.getRuntimeById(runtimeId);
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { pluginId, runtime } = registration;
|
||||
runtimeLog.log(`Found plugin runtime "${runtimeId}" from plugin "${pluginId}"`);
|
||||
|
||||
try {
|
||||
// Create plugin context for runtime factory
|
||||
const pluginContext = await pluginRunner.createRuntimeContext(pluginId);
|
||||
if (!pluginContext) {
|
||||
runtimeLog.warn(`Plugin "${pluginId}" runtime factory context unavailable`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Instantiate the runtime via factory
|
||||
const factoryResult = runtime.factory(pluginContext);
|
||||
const instance = await (factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult));
|
||||
|
||||
if (!instance) {
|
||||
runtimeLog.warn(`Plugin "${pluginId}" runtime factory returned null`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wrap the plugin runtime to conform to AgentRuntime interface
|
||||
// The plugin may return its own interface, so we adapt if needed
|
||||
const wrappedRuntime = wrapPluginRuntime(instance, runtime.metadata.runtimeId, runtime.metadata.name);
|
||||
|
||||
return { runtime: wrappedRuntime, pluginId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeLog.error(`Plugin "${pluginId}" runtime factory error: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a plugin runtime instance to conform to the AgentRuntime interface.
|
||||
*
|
||||
* Plugin runtimes may return their own interface types. This function
|
||||
* adapts them to the standard AgentRuntime interface.
|
||||
*/
|
||||
function wrapPluginRuntime(
|
||||
instance: unknown,
|
||||
runtimeId: string,
|
||||
runtimeName: string,
|
||||
): AgentRuntime {
|
||||
// If it's already an AgentRuntime, return as-is
|
||||
if (isAgentRuntime(instance)) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Otherwise, wrap in a compatibility layer
|
||||
// The plugin should return something compatible with our interface
|
||||
// but we provide a defensive fallback
|
||||
runtimeLog.warn(`Plugin runtime "${runtimeId}" does not conform to AgentRuntime interface, wrapping with adapter`);
|
||||
|
||||
return {
|
||||
id: runtimeId,
|
||||
name: runtimeName,
|
||||
createSession: async (options: AgentRuntimeOptions) => {
|
||||
const adapter = instance as Record<string, unknown>;
|
||||
if (typeof adapter.createSession === "function") {
|
||||
const result = await adapter.createSession(options);
|
||||
return {
|
||||
session: (result as AgentSessionResult).session ?? (result as AgentSession),
|
||||
sessionFile: (result as AgentSessionResult).sessionFile,
|
||||
};
|
||||
}
|
||||
throw new Error(`Plugin runtime "${runtimeId}" does not implement createSession`);
|
||||
},
|
||||
promptWithFallback: async (session: AgentSession, prompt: string, options?: unknown) => {
|
||||
const adapter = instance as Record<string, unknown>;
|
||||
if (typeof adapter.promptWithFallback === "function") {
|
||||
return adapter.promptWithFallback(session, prompt, options);
|
||||
}
|
||||
// Fallback to default pi promptWithFallback
|
||||
const { promptWithFallback: pwf } = await import("./pi.js");
|
||||
return pwf(session, prompt, options);
|
||||
},
|
||||
describeModel: (session: AgentSession) => {
|
||||
const adapter = instance as Record<string, unknown>;
|
||||
if (typeof adapter.describeModel === "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (adapter.describeModel as (s: AgentSession) => string)(session);
|
||||
}
|
||||
// Fallback to default pi describeModel - use cached sync function
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { describeModel: dm } = require("./pi.js");
|
||||
return dm(session);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object conforms to AgentRuntime.
|
||||
*/
|
||||
function isAgentRuntime(obj: unknown): obj is AgentRuntime {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"id" in obj &&
|
||||
"name" in obj &&
|
||||
typeof (obj as AgentRuntime).createSession === "function" &&
|
||||
typeof (obj as AgentRuntime).promptWithFallback === "function" &&
|
||||
typeof (obj as AgentRuntime).describeModel === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an agent runtime based on the resolution context.
|
||||
*
|
||||
* Resolution algorithm:
|
||||
* 1. If runtimeHint is provided and non-empty:
|
||||
* a. Look up runtime by ID from plugin runner
|
||||
* b. If found: instantiate and return with wasConfigured=true
|
||||
* c. If not found: log structured warning, fall back to pi runtime
|
||||
* 2. If no runtimeHint:
|
||||
* a. Return the default pi runtime with wasConfigured=false
|
||||
*
|
||||
* @param context - Resolution context with purpose, hint, and plugin runner
|
||||
* @returns The resolved runtime with metadata about how it was selected
|
||||
*/
|
||||
export async function resolveRuntime(context: RuntimeResolutionContext): Promise<ResolvedRuntime> {
|
||||
const { sessionPurpose, runtimeHint, pluginRunner } = context;
|
||||
|
||||
// Case 1: No runtime hint provided — use default pi runtime
|
||||
if (!runtimeHint || runtimeHint.trim() === "") {
|
||||
runtimeLog.log(`[${sessionPurpose}] No runtime hint configured, using default pi runtime`);
|
||||
return {
|
||||
runtime: getDefaultPiRuntime(),
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Runtime hint provided — try to find matching plugin runtime
|
||||
const runtimeId = runtimeHint.trim();
|
||||
|
||||
// Check if the hint is explicitly "pi" — use default runtime
|
||||
if (runtimeId === "pi" || runtimeId === "default") {
|
||||
runtimeLog.log(`[${sessionPurpose}] Runtime hint is "pi/default", using default pi runtime`);
|
||||
return {
|
||||
runtime: getDefaultPiRuntime(),
|
||||
wasConfigured: true,
|
||||
runtimeId: "pi",
|
||||
};
|
||||
}
|
||||
|
||||
// Look up the plugin runtime
|
||||
try {
|
||||
const resolved = await resolvePluginRuntime(pluginRunner, runtimeId);
|
||||
|
||||
if (resolved) {
|
||||
runtimeLog.log(`[${sessionPurpose}] Using configured plugin runtime "${runtimeId}" from "${resolved.pluginId}"`);
|
||||
return {
|
||||
runtime: resolved.runtime,
|
||||
wasConfigured: true,
|
||||
runtimeId,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeLog.error(`[${sessionPurpose}] Error resolving plugin runtime "${runtimeId}": ${message}`);
|
||||
}
|
||||
|
||||
// Case 3: Runtime not found or error — fall back to pi with warning
|
||||
logRuntimeFallback(sessionPurpose, runtimeId, "not_found");
|
||||
return {
|
||||
runtime: getDefaultPiRuntime(),
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log structured fallback warning when configured runtime is unavailable.
|
||||
*/
|
||||
function logRuntimeFallback(
|
||||
sessionPurpose: SessionPurpose,
|
||||
requestedRuntimeId: string,
|
||||
reason: FallbackReason,
|
||||
): void {
|
||||
runtimeLog.warn(
|
||||
`[${sessionPurpose}] Runtime "${requestedRuntimeId}" unavailable (${reason}), falling back to default pi runtime`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RuntimeResolutionContext with a default plugin runner.
|
||||
*
|
||||
* When a subsystem has a pluginRunner, use this helper to create the context
|
||||
* with sensible defaults. The runtimeHint should come from the task/agent
|
||||
* configuration when available.
|
||||
*
|
||||
* @param sessionPurpose - The purpose of the session
|
||||
* @param pluginRunner - The plugin runner for runtime lookup
|
||||
* @param runtimeHint - Optional runtime hint from task/agent configuration
|
||||
* @returns The resolution context
|
||||
*/
|
||||
export function buildRuntimeResolutionContext(
|
||||
sessionPurpose: SessionPurpose,
|
||||
pluginRunner: PluginRunner | undefined,
|
||||
runtimeHint?: string,
|
||||
): RuntimeResolutionContext {
|
||||
return {
|
||||
sessionPurpose,
|
||||
runtimeHint,
|
||||
pluginRunner: pluginRunner ?? createNoOpPluginRunner(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a no-op plugin runner for subsystems that don't have access to the real one.
|
||||
* This allows the resolver to still return the default pi runtime.
|
||||
*/
|
||||
function createNoOpPluginRunner(): PluginRunner {
|
||||
return {
|
||||
getPluginRuntimes: () => [],
|
||||
getRuntimeById: () => undefined,
|
||||
createRuntimeContext: async () => null,
|
||||
} as unknown as PluginRunner;
|
||||
}
|
||||
273
packages/engine/src/runtime-selection-regression.test.ts
Normal file
273
packages/engine/src/runtime-selection-regression.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Runtime Selection Regression Tests
|
||||
*
|
||||
* These tests verify that engine subsystems correctly use the runtime resolution
|
||||
* system and fall back to the default pi runtime when no runtime hint is configured.
|
||||
*
|
||||
* Key behaviors tested:
|
||||
* 1. Subsystems with pluginRunner option can resolve plugin runtimes
|
||||
* 2. Subsystems without pluginRunner or hint fall back to pi runtime
|
||||
* 3. Runtime resolution logs are emitted correctly
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock the logger to suppress output during tests
|
||||
vi.mock("./logger.js", () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock pi.js to avoid actual session creation
|
||||
vi.mock("./pi.js", () => ({
|
||||
createFnAgent: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
sessionFile: undefined,
|
||||
}),
|
||||
promptWithFallback: vi.fn().mockResolvedValue(undefined),
|
||||
describeModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
|
||||
}));
|
||||
|
||||
// Mock the runtime resolution module
|
||||
const mockResolveRuntime = vi.fn();
|
||||
vi.mock("./runtime-resolution.js", () => ({
|
||||
resolveRuntime: (...args: unknown[]) => mockResolveRuntime(...args),
|
||||
buildRuntimeResolutionContext: vi.fn().mockReturnValue({
|
||||
sessionPurpose: "test",
|
||||
runtimeHint: undefined,
|
||||
pluginRunner: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock session skill context
|
||||
vi.mock("./session-skill-context.js", () => ({
|
||||
buildSessionSkillContext: vi.fn().mockResolvedValue({
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
}),
|
||||
buildSessionSkillContextSync: vi.fn().mockReturnValue({
|
||||
skillSelectionContext: undefined,
|
||||
resolvedSkillNames: [],
|
||||
skillSource: "none",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock agent instructions
|
||||
vi.mock("./agent-instructions.js", () => ({
|
||||
resolveAgentInstructions: vi.fn().mockResolvedValue(""),
|
||||
buildSystemPromptWithInstructions: vi.fn().mockImplementation((base) => base),
|
||||
resolveAgentInstructionsWithRatings: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
describe("Runtime Selection Regression Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default: resolveRuntime returns pi runtime
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "pi",
|
||||
name: "Default PI Runtime",
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createResolvedAgentSession", () => {
|
||||
it("should call resolveRuntime when creating a session", async () => {
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: {} as any,
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(mockResolveRuntime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use the resolved runtime's createSession method", async () => {
|
||||
const mockCreateSession = vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
model: { provider: "test", id: "test-model" },
|
||||
},
|
||||
});
|
||||
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "test-runtime",
|
||||
name: "Test Runtime",
|
||||
createSession: mockCreateSession,
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "test/model",
|
||||
},
|
||||
wasConfigured: true,
|
||||
runtimeId: "test-runtime",
|
||||
});
|
||||
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: {} as any,
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(mockCreateSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return runtime metadata along with session", async () => {
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "my-runtime",
|
||||
name: "My Runtime",
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
model: { provider: "test", id: "test-model" },
|
||||
},
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "test/model",
|
||||
},
|
||||
wasConfigured: true,
|
||||
runtimeId: "my-runtime",
|
||||
});
|
||||
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "triage",
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("my-runtime");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default pi runtime fallback", () => {
|
||||
it("should fall back to pi runtime when no runtime hint provided", async () => {
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "pi",
|
||||
name: "Default PI Runtime",
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
},
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
});
|
||||
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "merger",
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to pi runtime when runtime hint references non-existent runtime", async () => {
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "pi",
|
||||
name: "Default PI Runtime",
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
},
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
});
|
||||
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "heartbeat",
|
||||
pluginRunner: {} as any,
|
||||
runtimeHint: "non-existent-runtime",
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plugin runtime selection", () => {
|
||||
it("should use configured plugin runtime when hint matches", async () => {
|
||||
mockResolveRuntime.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "code-interpreter",
|
||||
name: "Code Interpreter Runtime",
|
||||
createSession: async () => ({
|
||||
session: {
|
||||
model: { provider: "custom", id: "custom-model" },
|
||||
},
|
||||
}),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: () => "custom/model",
|
||||
},
|
||||
wasConfigured: true,
|
||||
runtimeId: "code-interpreter",
|
||||
});
|
||||
|
||||
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
|
||||
|
||||
const result = await createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
pluginRunner: {
|
||||
getRuntimeById: vi.fn().mockReturnValue({
|
||||
pluginId: "code-plugin",
|
||||
runtime: {
|
||||
metadata: { runtimeId: "code-interpreter", name: "Code Interpreter" },
|
||||
factory: vi.fn(),
|
||||
},
|
||||
}),
|
||||
} as any,
|
||||
runtimeHint: "code-interpreter",
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Test prompt",
|
||||
});
|
||||
|
||||
expect(result.runtimeId).toBe("code-interpreter");
|
||||
expect(result.wasConfigured).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -255,6 +255,7 @@ export class InProcessRuntime
|
||||
}
|
||||
: undefined,
|
||||
rootDir: this.config.workingDirectory,
|
||||
pluginRunner: this.pluginRunner,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -452,6 +453,7 @@ export class InProcessRuntime
|
||||
taskStore: this.taskStore,
|
||||
rootDir: this.config.workingDirectory,
|
||||
messageStore: this.messageStore,
|
||||
pluginRunner: this.pluginRunner,
|
||||
onMissed: (agentId) => {
|
||||
runtimeLog.warn(`Agent ${agentId} missed heartbeat`);
|
||||
},
|
||||
@@ -596,6 +598,7 @@ export class InProcessRuntime
|
||||
semaphore: this.globalSemaphore,
|
||||
stuckTaskDetector: this.stuckTaskDetector,
|
||||
agentStore: this.agentStore,
|
||||
pluginRunner: this.pluginRunner,
|
||||
onSpecifyStart: (t) => {
|
||||
this.recordActivity();
|
||||
runtimeLog.log(`Specifying ${t.id}...`);
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
ToolDefinition,
|
||||
AgentSession,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { createFnAgent, describeModel, promptWithFallback } from "./pi.js";
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { createResolvedAgentSession } from "./agent-session-helpers.js";
|
||||
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
|
||||
@@ -290,6 +291,8 @@ export interface TriageProcessorOptions {
|
||||
onAgentText?: (taskId: string, delta: string) => void;
|
||||
/** AgentStore for resolving per-agent custom instructions. */
|
||||
agentStore?: import("@fusion/core").AgentStore;
|
||||
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
|
||||
pluginRunner?: import("./plugin-runner.js").PluginRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,7 +737,9 @@ export class TriageProcessor {
|
||||
projectRootDir: this.rootDir,
|
||||
});
|
||||
|
||||
let { session } = await createFnAgent({
|
||||
let { session } = await createResolvedAgentSession({
|
||||
sessionPurpose: "triage",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: triageSystemPrompt,
|
||||
tools: "coding",
|
||||
@@ -914,7 +919,9 @@ export class TriageProcessor {
|
||||
specReviewVerdictRef.current = null;
|
||||
approvedCommentFingerprintRef.current = "";
|
||||
|
||||
const fallbackResult = await createFnAgent({
|
||||
const fallbackResult = await createResolvedAgentSession({
|
||||
sessionPurpose: "triage",
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: triageSystemPrompt,
|
||||
tools: "coding",
|
||||
|
||||
Reference in New Issue
Block a user