mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
This adds experimental support for virtual models. A virtual model is a catalog entry that an extension registers with pi.registerVirtualModel(). It does not talk to a provider itself; for every request it picks a physical model and thinking level. The motivation is to make routing policies pluggable: plan on a strong model and implement on a cheap one, pick a model with a classifier, or move to a larger window when the context grows. Today this needs model switching by hand or hacks in prepareRequest. The selection stays virtual: model_change records it, resume restores it, and each response records the physical model and thinking level that produced it. Router state can be stored on the session branch, so it follows /tree and forks and survives compaction. Context limits, compaction and image sizing use the physical model a request was routed to. examples/extensions/jev-router.ts shows a full router that uses the Jev classifier to pick Sol or Terra for planning and hands off to Luna after the first edit. docs/virtual-models.md describes the API.
233 lines
7.6 KiB
TypeScript
233 lines
7.6 KiB
TypeScript
import { join } from "node:path";
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
import type { Model } from "@earendil-works/pi-ai";
|
|
import { getAgentDir } from "../config.ts";
|
|
import { resolvePath } from "../utils/paths.ts";
|
|
import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
|
|
import { ModelRuntime } from "./model-runtime.ts";
|
|
import {
|
|
DefaultResourceLoader,
|
|
type DefaultResourceLoaderOptions,
|
|
type ResourceLoader,
|
|
type ResourceLoaderReloadOptions,
|
|
} from "./resource-loader.ts";
|
|
import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts";
|
|
import type { SessionManager } from "./session-manager.ts";
|
|
import { SettingsManager } from "./settings-manager.ts";
|
|
|
|
/**
|
|
* Non-fatal issues collected while creating services or sessions.
|
|
*
|
|
* Runtime creation returns diagnostics to the caller instead of printing or
|
|
* exiting. The app layer decides whether warnings should be shown and whether
|
|
* errors should abort startup.
|
|
*/
|
|
export interface AgentSessionRuntimeDiagnostic {
|
|
type: "info" | "warning" | "error";
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Inputs for creating cwd-bound runtime services.
|
|
*
|
|
* These services are recreated whenever the effective session cwd changes.
|
|
* CLI-provided resource paths should be resolved to absolute paths before they
|
|
* reach this function, so later cwd switches do not reinterpret them.
|
|
*/
|
|
export interface CreateAgentSessionServicesOptions {
|
|
cwd: string;
|
|
agentDir?: string;
|
|
settingsManager?: SettingsManager;
|
|
modelRuntime?: ModelRuntime;
|
|
modelRuntimeSignal?: AbortSignal;
|
|
extensionFlagValues?: Map<string, boolean | string>;
|
|
resourceLoaderOptions?: Omit<DefaultResourceLoaderOptions, "cwd" | "agentDir" | "settingsManager">;
|
|
resourceLoaderReloadOptions?: ResourceLoaderReloadOptions;
|
|
}
|
|
|
|
/**
|
|
* Inputs for creating an AgentSession from already-created services.
|
|
*
|
|
* Use this after services exist and any cwd-bound model/tool/session options
|
|
* have been resolved against those services.
|
|
*/
|
|
export interface CreateAgentSessionFromServicesOptions {
|
|
services: AgentSessionServices;
|
|
sessionManager: SessionManager;
|
|
sessionStartEvent?: SessionStartEvent;
|
|
model?: Model<any>;
|
|
thinkingLevel?: ThinkingLevel;
|
|
scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
|
|
tools?: string[];
|
|
excludeTools?: CreateAgentSessionOptions["excludeTools"];
|
|
noTools?: CreateAgentSessionOptions["noTools"];
|
|
customTools?: ToolDefinition[];
|
|
}
|
|
|
|
/**
|
|
* Coherent cwd-bound runtime services for one effective session cwd.
|
|
*
|
|
* This is infrastructure only. The AgentSession itself is created separately so
|
|
* session options can be resolved against these services first.
|
|
*/
|
|
export interface AgentSessionServices {
|
|
cwd: string;
|
|
agentDir: string;
|
|
modelRuntime: ModelRuntime;
|
|
settingsManager: SettingsManager;
|
|
resourceLoader: ResourceLoader;
|
|
diagnostics: AgentSessionRuntimeDiagnostic[];
|
|
}
|
|
|
|
function applyExtensionFlagValues(
|
|
resourceLoader: ResourceLoader,
|
|
extensionFlagValues: Map<string, boolean | string> | undefined,
|
|
): AgentSessionRuntimeDiagnostic[] {
|
|
if (!extensionFlagValues) {
|
|
return [];
|
|
}
|
|
|
|
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
|
const extensionsResult = resourceLoader.getExtensions();
|
|
const registeredFlags = new Map<string, { type: "boolean" | "string" }>();
|
|
for (const extension of extensionsResult.extensions) {
|
|
for (const [name, flag] of extension.flags) {
|
|
registeredFlags.set(name, { type: flag.type });
|
|
}
|
|
}
|
|
|
|
const unknownFlags: string[] = [];
|
|
for (const [name, value] of extensionFlagValues) {
|
|
const flag = registeredFlags.get(name);
|
|
if (!flag) {
|
|
unknownFlags.push(name);
|
|
continue;
|
|
}
|
|
if (flag.type === "boolean") {
|
|
extensionsResult.runtime.flagValues.set(name, true);
|
|
continue;
|
|
}
|
|
if (typeof value === "string") {
|
|
extensionsResult.runtime.flagValues.set(name, value);
|
|
continue;
|
|
}
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: `Extension flag "--${name}" requires a value`,
|
|
});
|
|
}
|
|
|
|
if (unknownFlags.length > 0) {
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: `Unknown option${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((name) => `--${name}`).join(", ")}`,
|
|
});
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
/**
|
|
* Create cwd-bound runtime services.
|
|
*
|
|
* Returns services plus diagnostics. It does not create an AgentSession.
|
|
*/
|
|
export async function createAgentSessionServices(
|
|
options: CreateAgentSessionServicesOptions,
|
|
): Promise<AgentSessionServices> {
|
|
const cwd = resolvePath(options.cwd);
|
|
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir();
|
|
const modelRuntime =
|
|
options.modelRuntime ??
|
|
(await ModelRuntime.create({
|
|
authPath: join(agentDir, "auth.json"),
|
|
modelsPath: join(agentDir, "models.json"),
|
|
signal: options.modelRuntimeSignal,
|
|
}));
|
|
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
|
|
const resourceLoader = new DefaultResourceLoader({
|
|
...(options.resourceLoaderOptions ?? {}),
|
|
cwd,
|
|
agentDir,
|
|
settingsManager,
|
|
});
|
|
await resourceLoader.reload(options.resourceLoaderReloadOptions);
|
|
|
|
const diagnostics: AgentSessionRuntimeDiagnostic[] = [];
|
|
const extensionsResult = resourceLoader.getExtensions();
|
|
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
|
|
try {
|
|
modelRuntime.registerProvider(name, config);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: `Extension "${extensionPath}" error: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
extensionsResult.runtime.pendingProviderRegistrations = [];
|
|
for (const { provider, extensionPath } of extensionsResult.runtime.pendingNativeProviderRegistrations) {
|
|
try {
|
|
modelRuntime.registerNativeProvider(provider);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: `Extension "${extensionPath}" error: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
extensionsResult.runtime.pendingNativeProviderRegistrations = [];
|
|
for (const { definition, extensionPath } of extensionsResult.runtime.pendingVirtualModelRegistrations) {
|
|
try {
|
|
modelRuntime.registerVirtualModel(definition);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
diagnostics.push({
|
|
type: "error",
|
|
message: `Extension "${extensionPath}" error: ${message}`,
|
|
});
|
|
}
|
|
}
|
|
extensionsResult.runtime.pendingVirtualModelRegistrations = [];
|
|
await modelRuntime.refresh({ allowNetwork: false });
|
|
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
|
|
|
|
return {
|
|
cwd,
|
|
agentDir,
|
|
modelRuntime,
|
|
settingsManager,
|
|
resourceLoader,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create an AgentSession from previously created services.
|
|
*
|
|
* This keeps session creation separate from service creation so callers can
|
|
* resolve model, thinking, tools, and other session inputs against the target
|
|
* cwd before constructing the session.
|
|
*/
|
|
export async function createAgentSessionFromServices(
|
|
options: CreateAgentSessionFromServicesOptions,
|
|
): Promise<CreateAgentSessionResult> {
|
|
return createAgentSession({
|
|
cwd: options.services.cwd,
|
|
agentDir: options.services.agentDir,
|
|
modelRuntime: options.services.modelRuntime,
|
|
settingsManager: options.services.settingsManager,
|
|
resourceLoader: options.services.resourceLoader,
|
|
sessionManager: options.sessionManager,
|
|
model: options.model,
|
|
thinkingLevel: options.thinkingLevel,
|
|
scopedModels: options.scopedModels,
|
|
tools: options.tools,
|
|
excludeTools: options.excludeTools,
|
|
noTools: options.noTools,
|
|
customTools: options.customTools,
|
|
sessionStartEvent: options.sessionStartEvent,
|
|
});
|
|
}
|