diff --git a/docs/architecture.md b/docs/architecture.md index 4fc2604..b2d3f05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,15 +15,27 @@ both explicitly and may use pi's orchestration primitives directly. `@onkernel/loop` is one package with two entry points and three source trees: - `.` (`src/core/`) is the framework-neutral core: canonical actions, the tool - declarations namespace, the catalog compiler, the tool menu, the tool manager, - and Kernel-browser execution (translator, CDP executor, execution resources). - Catalog compilation is declaration-only and deterministic. Its coupling to pi - is type-level — `Api`, `Model`, `Tool`, `AgentTool` — except for the model - resolution and provider modules it still reaches into, which the next split - moves behind an interface. -- `./pi` (`src/pi/`) is the pi binding: `attach()`/`compile()`, model - resolution, transport derivation, the provider adapters, provider retry, and - header composition. + declarations namespace, the catalog compiler, the tool menu, and + Kernel-browser execution (translator, CDP executor, execution resources). + Catalog compilation is declaration-only and deterministic. The core imports + nothing from pi — declarations are `LoopToolDeclaration`, executables are + `LoopExecutableTool` with an `(input, signal)` contract, models are the + neutral `LoopCatalogModel` view, and schemas come from `typebox` directly. + Per-model availability (capability quirks, native-surface tables) and + provider request preparation stay on the pi side: the binding hands the + compiler `LoopModelFacts` and `model-preparation` transforms as inputs, and + core only orders and validates what it is given. + `test/core-boundary.test.ts` fails the unit suite on any `src/core` import + that is not core-relative or an allowlisted neutral dependency — including + pi packages and this package's own `@onkernel/loop/pi` subpath. +- `./pi` (`src/pi/`) is the pi binding: `attach()`/`compile()`, the tool + manager that joins compiled catalogs to executable pi `AgentTool`s, model + resolution and availability facts (`compileLoopToolCatalog`/`loopToolMenu` + accept provider-qualified refs here and supply `LoopModelFacts`, + `modelSupportsDeferredTools` interprets pi compat flags, and the published + `loop` namespace composes `loop.providers.anthropic.supports` over core's + declarations), transport derivation, the provider adapters, provider retry, + and header composition. - `src/pi-extension/` contributes these tools to a pi session that pi itself owns. It is the one consumer that uses neither `attach()` nor the harness: pi owns the model collection and the agent loop, so the extension takes the two diff --git a/package-lock.json b/package-lock.json index 9d4a9b8..963a95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3996,7 +3996,8 @@ "@earendil-works/pi-ai": "0.83.0", "@onkernel/sdk": "0.49.0", "openai": "^6.26.0", - "sharp": "^0.35.3" + "sharp": "^0.35.3", + "typebox": "1.3.7" }, "devDependencies": { "@earendil-works/pi-coding-agent": "0.83.0", diff --git a/packages/loop/README.md b/packages/loop/README.md index a03e5dc..a6e203a 100644 --- a/packages/loop/README.md +++ b/packages/loop/README.md @@ -8,7 +8,7 @@ Two entry points: | import | what it is | | --- | --- | -| `@onkernel/loop` | The framework-neutral core: canonical actions, the tool namespace, catalog compilation, the tool menu, and Kernel-browser execution. | +| `@onkernel/loop` | The framework-neutral core: canonical actions, the tool namespace, catalog compilation, the tool menu, and Kernel-browser execution. Core declarations (`LoopToolDeclaration`) and executables (`LoopExecutableTool`) import nothing from pi — schemas come from `typebox` directly — and a unit test enforces the boundary. | | `@onkernel/loop/pi` | The pi binding: `attach()`, model resolution, transport derivation, provider adapters, and provider retry. | Installing the package into pi (`pi install npm:@onkernel/loop`) registers the @@ -373,11 +373,11 @@ themselves: ```ts const catalog = compileLoopToolCatalog({ model: "anthropic:claude-opus-5", - requestedTools: tools, // Loop specs and plain pi-ai Tool declarations + requestedTools: tools, // Loop specs and plain declarations ({ name, description, parameters }) }); catalog.entries; // identities, fingerprints, declarations, coordinates -catalog.toolDeclarations; // pi-ai Tool declarations for Context.tools +catalog.toolDeclarations; // LoopToolDeclarations, structurally pi-ai Tools, for Context.tools catalog.headers.merge(callerHeaders); await catalog.payload.apply(payload, catalog.model); catalog.incoming; diff --git a/packages/loop/package.json b/packages/loop/package.json index df33c37..ca86910 100644 --- a/packages/loop/package.json +++ b/packages/loop/package.json @@ -67,7 +67,8 @@ "@earendil-works/pi-ai": "0.83.0", "@onkernel/sdk": "0.49.0", "openai": "^6.26.0", - "sharp": "^0.35.3" + "sharp": "^0.35.3", + "typebox": "1.3.7" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*" diff --git a/packages/loop/src/core/actions/browser.ts b/packages/loop/src/core/actions/browser.ts index 5c9b2c9..c55d5cc 100644 --- a/packages/loop/src/core/actions/browser.ts +++ b/packages/loop/src/core/actions/browser.ts @@ -1,4 +1,4 @@ -import { Type, type TSchema } from "@earendil-works/pi-ai"; +import { Type, type TSchema } from "typebox"; /** * Browser-plane canonical actions. diff --git a/packages/loop/src/core/actions/computer.ts b/packages/loop/src/core/actions/computer.ts index a7c06c1..5e6aa9d 100644 --- a/packages/loop/src/core/actions/computer.ts +++ b/packages/loop/src/core/actions/computer.ts @@ -1,4 +1,4 @@ -import { Type, type TSchema } from "@earendil-works/pi-ai"; +import { Type, type TSchema } from "typebox"; /** * Computer-plane canonical actions. diff --git a/packages/loop/src/core/actions/index.ts b/packages/loop/src/core/actions/index.ts index b778961..943b192 100644 --- a/packages/loop/src/core/actions/index.ts +++ b/packages/loop/src/core/actions/index.ts @@ -1,4 +1,4 @@ -import type { TSchema } from "@earendil-works/pi-ai"; +import type { TSchema } from "typebox"; import { BROWSER_ACTION_TYPES, createBrowserActionSchemaByType, type BrowserAction, type BrowserActionType, type BrowserActionSchemaOptions } from "./browser"; import { COMPUTER_ACTION_SCHEMA_BY_TYPE, COMPUTER_ACTION_TYPES, type ComputerAction, type ComputerActionType } from "./computer"; diff --git a/packages/loop/src/pi/providers/anthropic/native.ts b/packages/loop/src/core/anthropic-native.ts similarity index 99% rename from packages/loop/src/pi/providers/anthropic/native.ts rename to packages/loop/src/core/anthropic-native.ts index 7e018d1..0fda94a 100644 --- a/packages/loop/src/pi/providers/anthropic/native.ts +++ b/packages/loop/src/core/anthropic-native.ts @@ -1,4 +1,4 @@ -import type { ComputerUseAction, MouseButton } from "../../../core/actions/index"; +import type { ComputerUseAction, MouseButton } from "./actions/index"; interface NativeInput { action: string; diff --git a/packages/loop/src/core/menu.ts b/packages/loop/src/core/menu.ts index d8a1227..5335159 100644 --- a/packages/loop/src/core/menu.ts +++ b/packages/loop/src/core/menu.ts @@ -1,6 +1,5 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { LoopCatalogModel, LoopModelFacts } from "./model-info"; import { loop } from "./tools"; -import { getLoopModel, type LoopModelRef } from "../pi/models"; import { compileLoopToolCatalog, type LoopToolSpec } from "./tool-catalog"; /** Where a menu entry comes from, for grouping in a picker. */ @@ -40,17 +39,17 @@ export interface LoopToolMenuEntry { * per-tool verdict. */ export function loopToolMenu( - model: LoopModelRef | Model, + model: LoopCatalogModel, selected: readonly LoopToolSpec[] = [], + facts?: LoopModelFacts, ): LoopToolMenuEntry[] { - const resolved = typeof model === "string" ? getLoopModel(model) : model; const selectedIdentities = new Set(selected.map((tool) => tool.identity)); return offerableEntries().map((entry) => { const isSelected = entry.tools.every((tool) => selectedIdentities.has(tool.identity)); const candidate = isSelected ? [...selected] : [...selected.filter((tool) => !entry.tools.some((offered) => offered.identity === tool.identity)), ...entry.tools]; - const failure = compileFailure(resolved, candidate); + const failure = compileFailure(model, facts, candidate); return { key: entry.key, label: entry.label, @@ -64,9 +63,9 @@ export function loopToolMenu( }); } -function compileFailure(model: Model, requestedTools: readonly LoopToolSpec[]): string | undefined { +function compileFailure(model: LoopCatalogModel, facts: LoopModelFacts | undefined, requestedTools: readonly LoopToolSpec[]): string | undefined { try { - compileLoopToolCatalog({ model, requestedTools }); + compileLoopToolCatalog({ model, requestedTools, facts }); return undefined; } catch (error) { return error instanceof Error ? error.message : String(error); diff --git a/packages/loop/src/core/model-info.ts b/packages/loop/src/core/model-info.ts new file mode 100644 index 0000000..3364d4d --- /dev/null +++ b/packages/loop/src/core/model-info.ts @@ -0,0 +1,53 @@ +/** The model identity fields Loop's core consults. */ +export interface LoopModelIdentity { + readonly provider: string; + readonly id: string; +} + +/** A provider-native tool surface Loop can offer for a model. */ +export type ComputerUseNativeSurface = "computer" | "browser"; + +/** Loop tool-catalog capabilities for a concrete model. */ +export interface LoopModelCapabilities { + readonly acceptsComplexSchemas: boolean; + readonly acceptsLargeSchemas: boolean; + readonly serializesStateMutations: boolean; +} + +/** + * Framework-neutral view of the model a catalog is compiled for: identity and + * the transport it carries. A pi-ai `Model` satisfies this shape structurally; + * core never sees more of it. + */ +export interface LoopCatalogModel extends LoopModelIdentity { + readonly api: string; +} + +/** + * Per-model availability facts the compiler and menu consult. The binding + * supplies them — pi derives them from its model registry and quirk tables — + * so core never owns a provider capability lookup. Absent facts mean + * permissive capabilities and no native surfaces. + */ +export interface LoopModelFacts { + /** Request-shape limits for the model. Absent means permissive. */ + readonly capabilities?: LoopModelCapabilities; + /** Provider-native tool surfaces the model can carry. Absent means none. */ + readonly nativeSurfaces?: readonly ComputerUseNativeSurface[]; +} + +const PERMISSIVE_CAPABILITIES: LoopModelCapabilities = Object.freeze({ + acceptsComplexSchemas: true, + acceptsLargeSchemas: true, + serializesStateMutations: false, +}); + +/** The capabilities a facts object carries, defaulting to permissive. */ +export function loopModelFactsCapabilities(facts: LoopModelFacts | undefined): LoopModelCapabilities { + return facts?.capabilities ?? PERMISSIVE_CAPABILITIES; +} + +/** The native surfaces a facts object carries, defaulting to none. */ +export function loopModelFactsNativeSurfaces(facts: LoopModelFacts | undefined): readonly ComputerUseNativeSurface[] { + return facts?.nativeSurfaces ?? []; +} diff --git a/packages/loop/src/core/resources.ts b/packages/loop/src/core/resources.ts index 617948e..b103297 100644 --- a/packages/loop/src/core/resources.ts +++ b/packages/loop/src/core/resources.ts @@ -1,5 +1,3 @@ -import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type Kernel from "@onkernel/sdk"; import type { ComputerUseAction } from "./actions/index"; import type { LoopCoordinateContract, LoopToolSpec } from "./tool-catalog"; @@ -22,7 +20,28 @@ export interface LoopExecutionDetails { error?: string; } -type ToolContent = Array; +/** One content block returned to the model by a materialized Loop tool. */ +export type LoopToolResultContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + +/** Framework-neutral result of executing a materialized Loop tool. */ +export interface LoopToolExecutionResult { + content: LoopToolResultContent[]; + details: LoopExecutionDetails; +} + +/** + * Framework-neutral executable: a spec bound to this pool's browser. A + * framework binding wraps `execute` in its own tool shape; the executable + * itself only ever sees the model-provided input and an abort signal. + */ +export interface LoopExecutableTool { + readonly spec: LoopToolSpec; + execute(input: unknown, signal?: AbortSignal): Promise; +} + +type ToolContent = LoopToolResultContent[]; /** * One per-agent browser resource pool. Tool catalogs may be rebuilt without @@ -33,7 +52,7 @@ export class LoopExecutionResources { readonly client: Kernel; private readonly translator: InternalComputerTranslator; /** Each spec is materialized exactly once per resource pool. */ - private readonly materialized = new WeakMap(); + private readonly materialized = new WeakMap(); constructor(options: { browser: KernelBrowser; @@ -46,17 +65,12 @@ export class LoopExecutionResources { this.translator = new InternalComputerTranslator(options); } - materialize(spec: LoopToolSpec): AgentTool { + materialize(spec: LoopToolSpec): LoopExecutableTool { const cached = this.materialized.get(spec); if (cached) return cached; - const definition = spec.declaration; - const tool: AgentTool = { - name: spec.name, - label: spec.name, - description: definition.description, - parameters: definition.parameters, - executionMode: "sequential", - execute: async (_toolCallId, input, signal) => { + const tool: LoopExecutableTool = { + spec, + execute: async (input, signal) => { if (spec.execution.kind === "playwright") return this.executePlaywright(spec.name, input); const actions = spec.execution.toActions(input); return this.executeActions(spec, actions, signal); @@ -82,7 +96,7 @@ export class LoopExecutionResources { this.translator.dispose(); } - private async executeActions(spec: LoopToolSpec, actions: ComputerUseAction[], signal?: AbortSignal): Promise> { + private async executeActions(spec: LoopToolSpec, actions: ComputerUseAction[], signal?: AbortSignal): Promise { if (spec.execution.kind !== "actions") throw new Error(`tool "${spec.name}" has no action executor`); let result: BatchExecutionResult; let failure: BatchExecutionError | undefined; @@ -124,7 +138,7 @@ export class LoopExecutionResources { }; } - private async executePlaywright(name: string, input: unknown): Promise> { + private async executePlaywright(name: string, input: unknown): Promise { const parameters = asRecord(input); const code = parameters.code; if (typeof code !== "string") throw new Error(`${name} requires string code`); @@ -207,7 +221,7 @@ function formatBrowserWaitResult(result: BrowserWaitForResult): string { return [`wait_for: ${result.status}/${result.evidence}${reason} after ${result.elapsed_ms}ms`, ...result.details].join("\n"); } -function toImage(screenshot: { data: Buffer; mimeType: string }): ImageContent { +function toImage(screenshot: { data: Buffer; mimeType: string }): LoopToolResultContent { return { type: "image", data: screenshot.data.toString("base64"), mimeType: screenshot.mimeType }; } diff --git a/packages/loop/src/core/tool-catalog.ts b/packages/loop/src/core/tool-catalog.ts index 261476f..595e767 100644 --- a/packages/loop/src/core/tool-catalog.ts +++ b/packages/loop/src/core/tool-catalog.ts @@ -1,17 +1,31 @@ -import type { Api, Model, Tool } from "@earendil-works/pi-ai"; +import type { TSchema } from "typebox"; import type { ComputerUseAction } from "./actions/index"; -import type { ComputerUseNativeSurface, LoopModelRef } from "../pi/models"; -import { computerUseNativeSurfaces, loopModelCapabilities, getLoopModel } from "../pi/models"; -import { anthropicAdaptiveThinkingOnPayload } from "../pi/providers/anthropic/adaptive-thinking"; import { - supportsAnthropicNativeBrowser, - supportsAnthropicNativeComputer, -} from "../pi/providers/anthropic/capabilities"; -import { GOOGLE_INTERACTIONS_API } from "../pi/providers/google/provider"; -import { OPENAI_COMPUTER_USE_API } from "../pi/providers/openai/provider"; + loopModelFactsCapabilities, + loopModelFactsNativeSurfaces, + type ComputerUseNativeSurface, + type LoopCatalogModel, + type LoopModelFacts, +} from "./model-info"; export const LOOP_TOOL_SPEC_KIND = "@onkernel/loop-tool-spec/v1" as const; +/** Loop-owned api id for OpenAI's native computer tool, derived onto the model by {@link compileLoopToolCatalog} when that tool is selected. */ +export const OPENAI_COMPUTER_USE_API = "openai-computer-use"; + +/** Loop-owned api id for Google's native computer-use toolset, derived onto the model by {@link compileLoopToolCatalog} when that toolset is selected. */ +export const GOOGLE_INTERACTIONS_API = "google-interactions"; + +/** + * Framework-neutral tool declaration: what a model is told about a tool. + * Structurally assignable to a pi-ai `Tool`. + */ +export interface LoopToolDeclaration { + readonly name: string; + readonly description: string; + readonly parameters: TSchema; +} + export type LoopToolOrigin = "loop" | "provider-native"; export type LoopToolTransport = "function" | "native"; export type LoopToolDynamicLoading = "eligible" | "eager-only"; @@ -42,14 +56,14 @@ export type LoopProviderBinding = readonly kind: "openai-native"; readonly declaration: Record; /** Transport this binding requires the compiled catalog's model to carry. */ - readonly requiresApi?: Api; + readonly requiresApi?: string; } | { readonly kind: "google-native"; readonly nativeName: string; readonly allNativeNames: readonly string[]; /** Transport this binding requires the compiled catalog's model to carry. */ - readonly requiresApi?: Api; + readonly requiresApi?: string; }; /** Declarative Loop tool. Identity is immutable and independent from its model-facing alias. */ @@ -63,7 +77,7 @@ export interface LoopToolSpec { readonly source?: string; readonly transport: LoopToolTransport; readonly dynamicLoading: LoopToolDynamicLoading; - readonly declaration: Tool; + readonly declaration: LoopToolDeclaration; /** @internal Local execution policy consumed by the tool manager. */ readonly execution: LoopToolExecution; /** @internal Provider transport contribution consumed by the catalog compiler. */ @@ -83,10 +97,10 @@ export interface LoopToolSpec { /** * Sanitized declarative projection of a caller-owned tool. The compiler never - * sees executors: callers pass plain pi-ai `Tool` declarations and the executing - * runtime keeps the matching implementation. + * sees executors: callers pass plain declarations and the executing runtime + * keeps the matching implementation. */ -export type LoopCallerToolDeclaration = Tool; +export type LoopCallerToolDeclaration = LoopToolDeclaration; /** Declarative catalog input: a Loop spec or a sanitized caller tool declaration. */ export type LoopCatalogToolInput = LoopToolSpec | LoopCallerToolDeclaration; @@ -107,7 +121,7 @@ export interface LoopToolInfo { source?: string; transport: LoopToolTransport; dynamicLoading: LoopToolDynamicLoading; - declaration: Tool | Record; + declaration: LoopToolDeclaration | Record; coordinates?: LoopCoordinateContract; } @@ -128,12 +142,12 @@ export interface LoopPayloadTransform { consumesToolIdentities?: readonly string[]; writes?: readonly string[]; phase: "model-preparation" | "tool-declarations" | "provider-fields"; - apply(payload: unknown, model: Model, names: ReadonlyMap): unknown | Promise; + apply(payload: unknown, model: LoopCatalogModel, names: ReadonlyMap): unknown | Promise; } export interface LoopPayloadPlan { readonly transforms: readonly LoopPayloadTransform[]; - apply(payload: unknown, model: Model): Promise; + apply(payload: unknown, model: LoopCatalogModel): Promise; } /** Function-tool fallback for an Anthropic native browser tool unavailable to the active credential. */ @@ -158,23 +172,44 @@ export interface LoopToolCatalogEntry extends LoopToolInfo { readonly fingerprint: string; } -export interface LoopToolCatalog { - readonly model: Model; +/** + * The model a compiled catalog carries: the input model with `api` widened to + * `string`, because compilation may replace it with a selected tool's derived + * transport. Keeping the widening in the type is what lets the input stay + * narrowly typed without the output lying about it. + */ +export type LoopCompiledModel = Omit & { readonly api: string }; + +export interface LoopToolCatalog { + readonly model: LoopCompiledModel; readonly entries: readonly LoopToolCatalogEntry[]; /** - * Provider-facing pi-ai `Tool` declarations in entry order, suitable for + * Provider-facing tool declarations in entry order, suitable for * `Context.tools`. Native placeholders are swapped by `payload` transforms. */ - readonly toolDeclarations: readonly Tool[]; + readonly toolDeclarations: readonly LoopToolDeclaration[]; readonly headers: LoopHeaderPlan; readonly payload: LoopPayloadPlan; readonly incoming: LoopIncomingToolPlan; readonly fingerprint: string; } -export interface CompileLoopToolCatalogOptions { - model: LoopModelRef | Model; +export interface CompileLoopToolCatalogOptions { + model: M; requestedTools: readonly LoopCatalogToolInput[]; + /** + * Binding-supplied availability facts for the model: request-shape + * capabilities and native tool surfaces. Absent facts mean permissive + * capabilities and no native surfaces. + */ + facts?: LoopModelFacts; + /** + * Binding-supplied `model-preparation` payload transforms, e.g. pi's + * Anthropic thinking-budget conversion. Compiled into the payload plan ahead + * of tool-declaration and provider-field transforms and validated against + * the same write claims. + */ + preparation?: readonly LoopPayloadTransform[]; } /** @@ -182,7 +217,7 @@ export interface CompileLoopToolCatalogOptions { * requested spec/declaration objects or provider bindings used to compile it. */ interface LoopCatalogEntryDraft extends LoopToolCatalogEntry { - readonly placeholder: Tool; + readonly placeholder: LoopToolDeclaration; readonly providerBinding?: LoopProviderBinding; readonly stateMutating?: boolean; readonly complexSchema?: boolean; @@ -203,18 +238,16 @@ const SAFE_TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; * no such tool selected keeps its ordinary registry `api`. Selecting tools * whose bindings require different transports fails to compile. */ -export function compileLoopToolCatalog(options: CompileLoopToolCatalogOptions): LoopToolCatalog { - const baseModel = typeof options.model === "string" - ? getLoopModel(options.model) - : resetCatalogDerivedApi(options.model); +export function compileLoopToolCatalog(options: CompileLoopToolCatalogOptions): LoopToolCatalog { + const baseModel = resetCatalogDerivedApi(options.model); const normalizedEntries = [...options.requestedTools].map(normalizeTool); - const requiresApi = validateCatalog(baseModel, normalizedEntries); - const model = requiresApi ? { ...baseModel, api: requiresApi } : baseModel; + const requiresApi = validateCatalog(baseModel, options.facts, normalizedEntries); + const model = requiresApi ? withApi(baseModel, requiresApi) : baseModel; const drafts = resolveProviderFacingDeclarations(normalizedEntries); const names = new Map(drafts.map((entry) => [entry.identity, entry.name])); const requirements = compileHeaderRequirements(drafts); - const transforms = compilePayloadTransforms(model, drafts); + const transforms = compilePayloadTransforms(model, options.facts, drafts, validatePreparation(options.preparation)); validateTransformClaims(transforms); const incoming = compileIncomingPlan(drafts); const fingerprint = stableStringify({ @@ -252,18 +285,6 @@ export function isLoopToolSpec(value: unknown): value is LoopToolSpec { return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === LOOP_TOOL_SPEC_KIND); } -export function modelSupportsDeferredTools(model: Model): boolean { - const compat = isRecord(model.compat) ? model.compat : undefined; - if (model.provider === "openai") return compat?.supportsToolSearch === true; - if (model.provider !== "anthropic" || model.id.toLowerCase().includes("haiku")) return false; - if (typeof compat?.supportsToolReferences === "boolean") return compat.supportsToolReferences; - const version = model.id.toLowerCase().match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/); - if (!version) return false; - const major = Number(version[1]); - const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0; - return major > 4 || (major === 4 && minor >= 5); -} - function normalizeTool(tool: LoopCatalogToolInput): LoopCatalogEntryDraft { if (isLoopToolSpec(tool)) { const schemaFingerprint = stableStringify(tool.declaration.parameters); @@ -337,7 +358,7 @@ function resolveProviderFacingDeclarations(entries: readonly LoopCatalogEntryDra } /** Validate the requested catalog against the model and return the transport its selected tools require, if any. */ -function validateCatalog(model: Model, entries: readonly LoopCatalogEntryDraft[]): Api | undefined { +function validateCatalog(model: LoopCatalogModel, facts: LoopModelFacts | undefined, entries: readonly LoopCatalogEntryDraft[]): string | undefined { const identities = new Map(); const exactNames = new Map(); const normalizedNames = new Map(); @@ -361,7 +382,7 @@ function validateCatalog(model: Model, entries: readonly LoopCatalogEntryDr const normalized = normalizedNames.get(key); if (normalized) throw nameCollision(entry.name, normalized, entry, model.provider); normalizedNames.set(key, entry); - validateToolCompatibility(model, entry); + validateToolCompatibility(model, facts, entry); } return validateToolsetCompatibility(model, entries); @@ -377,7 +398,7 @@ function nameCollision( return new Error(`tool name "${name}" is requested by both "${first.identity}" and "${second.identity}"${suffix}`); } -function validateToolCompatibility(model: Model, entry: LoopCatalogEntryDraft): void { +function validateToolCompatibility(model: LoopCatalogModel, facts: LoopModelFacts | undefined, entry: LoopCatalogEntryDraft): void { const binding = entry.providerBinding; if (entry.origin === "provider-native" && !/^https:\/\//.test(entry.source ?? "")) { throw new Error(`${entry.identity} must cite first-party provider documentation`); @@ -389,39 +410,36 @@ function validateToolCompatibility(model: Model, entry: LoopCatalogEntryDra throw new Error(`${entry.identity} requires a ${required} model; selected ${model.provider}:${model.id}`); } } - const capabilities = loopModelCapabilities(model); + const capabilities = loopModelFactsCapabilities(facts); if (entry.complexSchema && !capabilities.acceptsComplexSchemas) { throw new Error(`provider ${model.provider} does not accept the schema used by "${entry.name}" (${entry.identity})`); } if (entry.largeSchema && !capabilities.acceptsLargeSchemas) { throw new Error(`provider ${model.provider} does not accept the schema size of "${entry.name}" (${entry.identity})`); } - if (binding?.kind === "anthropic-native") validateAnthropicNativeModel(model, entry.identity); - else if (binding) validateNativeSurfaceModel(model, entry.identity, binding.kind === "google-native" ? "browser" : "computer"); + if (binding?.kind === "anthropic-native") validateAnthropicNativeModel(model, facts, entry.identity); + else if (binding) validateNativeSurfaceModel(model, facts, entry.identity, binding.kind === "google-native" ? "browser" : "computer"); } -function validateAnthropicNativeModel(model: Model, identity: string): void { - const computer = identity.includes(".computer."); - const supported = computer - ? supportsAnthropicNativeComputer(model.id) - : supportsAnthropicNativeBrowser(model.id); - if (!supported) { +function validateAnthropicNativeModel(model: LoopCatalogModel, facts: LoopModelFacts | undefined, identity: string): void { + const surface: ComputerUseNativeSurface = identity.includes(".computer.") ? "computer" : "browser"; + if (!loopModelFactsNativeSurfaces(facts).includes(surface)) { throw new Error(`${identity} does not support model "${model.id}"`); } } // A provider enables its native surface per model, not per provider: OpenAI's // computer tool and Google's `computer_use` both answer 400 on a model the -// surface is not enabled for. COMPUTER_USE_NATIVE_SURFACES is what the menu -// reads, so gate compilation on it too rather than letting the request fail on -// the wire. -function validateNativeSurfaceModel(model: Model, identity: string, surface: ComputerUseNativeSurface): void { - if (computerUseNativeSurfaces(model).includes(surface)) return; +// surface is not enabled for. The binding-supplied surface facts are what the +// menu reads, so gate compilation on them too rather than letting the request +// fail on the wire. +function validateNativeSurfaceModel(model: LoopCatalogModel, facts: LoopModelFacts | undefined, identity: string, surface: ComputerUseNativeSurface): void { + if (loopModelFactsNativeSurfaces(facts).includes(surface)) return; throw new Error(`${identity} does not support model "${model.id}": ${model.provider} does not offer a native ${surface} surface for it`); } /** Validate the selected native tools agree on a provider and a transport, and return the transport they require, if any. */ -function validateToolsetCompatibility(model: Model, entries: readonly LoopCatalogEntryDraft[]): Api | undefined { +function validateToolsetCompatibility(model: LoopCatalogModel, entries: readonly LoopCatalogEntryDraft[]): string | undefined { const nativeProviderKinds = new Set( entries.flatMap((entry) => entry.providerBinding ? [entry.providerBinding.kind.split("-")[0]] : []), ); @@ -456,7 +474,7 @@ function validateToolsetCompatibility(model: Model, entries: readonly LoopC } /** The transport a provider binding requires, if it declares one. Anthropic never forks transports and declares none. */ -function bindingRequiresApi(binding: LoopProviderBinding | undefined): readonly [Api] | readonly [] { +function bindingRequiresApi(binding: LoopProviderBinding | undefined): readonly [string] | readonly [] { return binding && binding.kind !== "anthropic-native" && binding.requiresApi ? [binding.requiresApi] : []; } @@ -469,14 +487,18 @@ function bindingRequiresApi(binding: LoopProviderBinding | undefined): readonly * with respect to the currently requested tools rather than pinning whatever * transport an earlier selection required. */ -const CATALOG_DERIVED_API_DEFAULTS: Readonly> = { +const CATALOG_DERIVED_API_DEFAULTS: Readonly> = { [OPENAI_COMPUTER_USE_API]: "openai-responses", [GOOGLE_INTERACTIONS_API]: "google-generative-ai", }; -function resetCatalogDerivedApi(model: Model): Model { +function resetCatalogDerivedApi(model: M): LoopCompiledModel { const defaultApi = CATALOG_DERIVED_API_DEFAULTS[model.api]; - return defaultApi ? { ...model, api: defaultApi } : model; + return defaultApi ? withApi(model, defaultApi) : model; +} + +function withApi(model: M | LoopCompiledModel, api: string): LoopCompiledModel { + return { ...model, api }; } function compileHeaderRequirements(entries: readonly LoopCatalogEntryDraft[]): LoopHeaderRequirement[] { @@ -522,18 +544,22 @@ function commaTokens(value: string | undefined): string[] { return value?.split(",").map((token) => token.trim()).filter(Boolean) ?? []; } -function compilePayloadTransforms(model: Model, entries: readonly LoopCatalogEntryDraft[]): LoopPayloadTransform[] { - const transforms: LoopPayloadTransform[] = []; - if (model.provider === "anthropic") { - transforms.push({ - identity: "provider.anthropic.model-preparation", - phase: "model-preparation", - writes: ["thinking", "output_config.effort"], - apply(payload, selectedModel) { - return anthropicAdaptiveThinkingOnPayload(payload, selectedModel) ?? payload; - }, - }); +function validatePreparation(preparation: readonly LoopPayloadTransform[] | undefined): readonly LoopPayloadTransform[] { + for (const transform of preparation ?? []) { + if (transform.phase !== "model-preparation") { + throw new Error(`preparation transform "${transform.identity}" must declare the "model-preparation" phase, not "${transform.phase}"`); + } } + return preparation ?? []; +} + +function compilePayloadTransforms( + model: LoopCatalogModel, + facts: LoopModelFacts | undefined, + entries: readonly LoopCatalogEntryDraft[], + preparation: readonly LoopPayloadTransform[], +): LoopPayloadTransform[] { + const transforms: LoopPayloadTransform[] = [...preparation]; for (const entry of entries) { const binding = entry.providerBinding; @@ -558,7 +584,7 @@ function compilePayloadTransforms(model: Model, entries: readonly LoopCatal transforms.push(createGeminiSchemaTransform()); } - if (loopModelCapabilities(model).serializesStateMutations && entries.some((entry) => entry.stateMutating)) { + if (loopModelFactsCapabilities(facts).serializesStateMutations && entries.some((entry) => entry.stateMutating)) { transforms.push({ identity: `provider.${model.provider}.serial-tool-calls`, writes: ["parallel_tool_calls"], @@ -669,7 +695,7 @@ function validateTransformClaims(transforms: readonly LoopPayloadTransform[]): v } function createPayloadPlan( - model: Model, + model: LoopCatalogModel, transforms: readonly LoopPayloadTransform[], names: ReadonlyMap, ): LoopPayloadPlan { diff --git a/packages/loop/src/core/tools.ts b/packages/loop/src/core/tools.ts index 25a7cb3..88a4654 100644 --- a/packages/loop/src/core/tools.ts +++ b/packages/loop/src/core/tools.ts @@ -1,4 +1,4 @@ -import { Type, type Tool, type TSchema } from "@earendil-works/pi-ai"; +import { Type, type TSchema } from "typebox"; import { COMPUTER_ACTION_TYPES, createBrowserActionSchemaByType, @@ -6,14 +6,14 @@ import { type BrowserActionType, type ComputerActionType, } from "./actions/index"; -import { supportsAnthropicNativeBrowser } from "../pi/providers/anthropic/capabilities"; -import { mapNativeBrowserInput, mapNativeComputerInput } from "../pi/providers/anthropic/native"; -import { GOOGLE_INTERACTIONS_API } from "../pi/providers/google/provider"; -import { OPENAI_COMPUTER_USE_API } from "../pi/providers/openai/provider"; +import { mapNativeBrowserInput, mapNativeComputerInput } from "./anthropic-native"; import { + GOOGLE_INTERACTIONS_API, LOOP_TOOL_SPEC_KIND, + OPENAI_COMPUTER_USE_API, type LoopCoordinateContract, type LoopProviderBinding, + type LoopToolDeclaration, type LoopToolDynamicLoading, type LoopToolExecution, type LoopToolOrigin, @@ -501,7 +501,7 @@ function createSpec(options: { source?: string; transport?: LoopToolTransport; dynamicLoading?: LoopToolDynamicLoading; - declaration: Tool; + declaration: LoopToolDeclaration; execution: LoopToolExecution; providerBinding?: LoopProviderBinding; stateMutating: boolean; @@ -843,7 +843,6 @@ const providers = Object.freeze({ openai: Object.freeze({ source: providerSources.openai, tools: Object.freeze({ computer: openaiNativeComputer }) }), anthropic: Object.freeze({ source: providerSources.anthropic, - supports: Object.freeze({ browser: supportsAnthropicNativeBrowser }), tools: Object.freeze({ computer: anthropicNativeComputer, browser: anthropicNativeBrowser }), }), google: Object.freeze({ @@ -852,7 +851,11 @@ const providers = Object.freeze({ }), }); -/** Frozen, discoverable tool namespace of every tool this package declares. */ +/** + * Frozen, discoverable tool namespace of every tool this package declares. + * The published `loop` on the package root is this namespace with the pi + * binding's availability helpers composed in (`loop.providers.anthropic.supports`). + */ export const loop = Object.freeze({ coordinates: Object.freeze({ pixels: () => pixels, normalized }), tools: Object.freeze({ browser: browserTools, computer: computerTools, playwright }), diff --git a/packages/loop/src/index.ts b/packages/loop/src/index.ts index 9eede9d..651ae25 100644 --- a/packages/loop/src/index.ts +++ b/packages/loop/src/index.ts @@ -1,11 +1,25 @@ export * from "./core/actions/index"; export * from "./core/menu"; +export * from "./core/model-info"; export * from "./core/tool-catalog"; export * from "./core/tools"; export { normalizeGotoUrl } from "./core/url"; -export type { LoopAgentTool, LoopHarnessTool } from "./core/tool-manager"; +// pi-flavored entry points: these shadow the star exports above, keeping +// provider-qualified model refs ("openai:gpt-5.5") and pi-supplied model +// availability working on this surface while src/core stays free of both. +export { compileLoopToolCatalog, loopToolMenu } from "./pi/catalog"; +export { loop, type LoopNamespace } from "./pi/loop"; +export { modelSupportsDeferredTools } from "./pi/models"; + +export type { LoopAgentTool, LoopHarnessTool } from "./pi/tool-manager"; export { LoopExecutionResources } from "./core/resources"; +export type { + LoopExecutableTool, + LoopExecutionDetails, + LoopToolExecutionResult, + LoopToolResultContent, +} from "./core/resources"; export { formatBrowserActResult } from "./core/browser-result-format"; export type { KernelBrowser } from "./core/translator/translator"; export { InternalComputerTranslator } from "./core/translator/translator"; diff --git a/packages/loop/src/pi-extension/index.ts b/packages/loop/src/pi-extension/index.ts index 2cdfe55..6a75083 100644 --- a/packages/loop/src/pi-extension/index.ts +++ b/packages/loop/src/pi-extension/index.ts @@ -62,7 +62,7 @@ export default function loopPiExtension(pi: ExtensionAPI): void { const selected = currentSpecs().find((candidate) => candidate.name === name); if (!selected || compatibilityError) throw new Error(compatibilityError ?? `browser tool "${name}" is no longer selected`); const resources = await ensureRuntime().get(signal); - return resources.materialize(selected).execute(toolCallId, input, signal); + return resources.materialize(selected).execute(input, signal); }, }); } @@ -132,7 +132,7 @@ export default function loopPiExtension(pi: ExtensionAPI): void { * when no Loop tool is active. Compiling is pure and cheap, so this re-derives * per request rather than caching a catalog that a model switch could stale. */ - function streamCatalog(model: Model): LoopToolCatalog | undefined { + function streamCatalog(model: Model): LoopToolCatalog> | undefined { if (!activeNames.size || compatibilityError) return undefined; try { return compileSpecs(model, activeSpecs()); @@ -161,11 +161,13 @@ export default function loopPiExtension(pi: ExtensionAPI): void { ...base, stream: (model, context, options) => { const catalog = streamCatalog(model); - return base.stream(catalog?.model ?? model, context, withPlan(options, catalog)); + const compiled: Model = catalog?.model ?? model; + return base.stream(compiled, context, withPlan(options, catalog)); }, streamSimple: (model, context, options) => { const catalog = streamCatalog(model); - return base.streamSimple(catalog?.model ?? model, context, withPlan(options, catalog)); + const compiled: Model = catalog?.model ?? model; + return base.streamSimple(compiled, context, withPlan(options, catalog)); }, }); } diff --git a/packages/loop/src/pi-extension/selection.ts b/packages/loop/src/pi-extension/selection.ts index 9dd83cc..0346159 100644 --- a/packages/loop/src/pi-extension/selection.ts +++ b/packages/loop/src/pi-extension/selection.ts @@ -101,7 +101,7 @@ export function expandSelection(selection: LoopSelection): LoopToolSpec[] { * what lets the extension validate a selection and generate headers before any * browser exists. */ -export function compileSpecs(model: Model, specs: readonly LoopToolSpec[]): LoopToolCatalog { +export function compileSpecs(model: Model, specs: readonly LoopToolSpec[]): LoopToolCatalog> { return compileLoopToolCatalog({ model, requestedTools: specs }); } diff --git a/packages/loop/src/pi/attach.ts b/packages/loop/src/pi/attach.ts index 59c3e46..c2d3d89 100644 --- a/packages/loop/src/pi/attach.ts +++ b/packages/loop/src/pi/attach.ts @@ -15,7 +15,7 @@ import type { import type Kernel from "@onkernel/sdk"; import { LoopExecutionResources, type LoopExecutionDetails } from "../core/resources"; import type { LoopIncomingToolPlan } from "../core/tool-catalog"; -import { LoopToolManager, type LoopHarnessTool } from "../core/tool-manager"; +import { LoopToolManager, type LoopHarnessTool } from "./tool-manager"; import type { KernelBrowser } from "../core/translator/translator"; import { getLoopModel, parseLoopModelRef, type LoopModelRef } from "./models"; import { resolveProviderRetryPolicy, type LoopRetryOptions, withProviderRetryModels } from "./provider-retry"; diff --git a/packages/loop/src/pi/catalog.ts b/packages/loop/src/pi/catalog.ts new file mode 100644 index 0000000..6f340b8 --- /dev/null +++ b/packages/loop/src/pi/catalog.ts @@ -0,0 +1,56 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { loopToolMenu as coreLoopToolMenu, type LoopToolMenuEntry } from "../core/menu"; +import { + compileLoopToolCatalog as compileCatalog, + type LoopCatalogToolInput, + type LoopPayloadTransform, + type LoopToolCatalog, + type LoopToolSpec, +} from "../core/tool-catalog"; +import { getLoopModel, loopModelFacts, type LoopModelRef } from "./models"; +import { anthropicAdaptiveThinkingOnPayload } from "./providers/anthropic/adaptive-thinking"; + +/** + * `model-preparation` payload transforms the pi binding contributes for a + * model. Provider-specific request preparation lives here, on the pi side of + * the boundary; the neutral compiler only orders and validates the transforms + * it is handed. + */ +export function loopModelPreparationTransforms(model: Model): LoopPayloadTransform[] { + if (model.provider !== "anthropic") return []; + return [{ + identity: "provider.anthropic.model-preparation", + phase: "model-preparation", + writes: ["thinking", "output_config.effort"], + apply(payload, selectedModel) { + return anthropicAdaptiveThinkingOnPayload(payload, selectedModel) ?? payload; + }, + }]; +} + +/** + * {@link compileCatalog} with pi model resolution: a provider-qualified ref is + * resolved through pi-ai's registry and compiled together with pi's + * availability facts and model-preparation transforms. + */ +export function compileLoopToolCatalog(options: { + model: LoopModelRef | Model; + requestedTools: readonly LoopCatalogToolInput[]; +}): LoopToolCatalog> { + const model = typeof options.model === "string" ? getLoopModel(options.model) : options.model; + return compileCatalog({ + model, + requestedTools: options.requestedTools, + facts: loopModelFacts(model), + preparation: loopModelPreparationTransforms(model), + }); +} + +/** {@link coreLoopToolMenu} with pi model resolution and availability facts. */ +export function loopToolMenu( + model: LoopModelRef | Model, + selected: readonly LoopToolSpec[] = [], +): LoopToolMenuEntry[] { + const resolved = typeof model === "string" ? getLoopModel(model) : model; + return coreLoopToolMenu(resolved, selected, loopModelFacts(resolved)); +} diff --git a/packages/loop/src/pi/loop.ts b/packages/loop/src/pi/loop.ts new file mode 100644 index 0000000..a9634f3 --- /dev/null +++ b/packages/loop/src/pi/loop.ts @@ -0,0 +1,20 @@ +import { loop as coreLoop } from "../core/tools"; +import { supportsAnthropicNativeBrowser } from "./providers/anthropic/capabilities"; + +/** + * The published tool namespace: core's declarations with the pi binding's + * per-model availability helpers composed in. Availability decisions live on + * the pi side of the boundary; core only declares the tools. + */ +export const loop = Object.freeze({ + ...coreLoop, + providers: Object.freeze({ + ...coreLoop.providers, + anthropic: Object.freeze({ + ...coreLoop.providers.anthropic, + supports: Object.freeze({ browser: supportsAnthropicNativeBrowser }), + }), + }), +}); + +export type LoopNamespace = typeof loop; diff --git a/packages/loop/src/pi/models.ts b/packages/loop/src/pi/models.ts index a60cdee..02f0a7b 100644 --- a/packages/loop/src/pi/models.ts +++ b/packages/loop/src/pi/models.ts @@ -1,16 +1,16 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"; +import type { ComputerUseNativeSurface, LoopModelCapabilities, LoopModelFacts } from "../core/model-info"; import { supportsAnthropicNativeBrowser, supportsAnthropicNativeComputer } from "./providers/anthropic/capabilities"; +export type { ComputerUseNativeSurface, LoopModelCapabilities } from "../core/model-info"; + /** A pi-ai provider id. Any provider pi-ai carries can be selected. */ export type LoopProvider = string; /** Provider-qualified model reference, e.g. `"openai:gpt-5.6-sol"` or `"google:gemini-3.6-flash"`. */ export type LoopModelRef = `${string}:${string}`; -/** A provider-native tool surface Loop can offer for a model. */ -export type ComputerUseNativeSurface = "computer" | "browser"; - /** One entry returned by {@link listLoopModels}. */ export interface LoopModelInfo { /** Provider-qualified ref accepted by {@link getLoopModel}. */ @@ -39,13 +39,6 @@ export type LoopModelMatch = | { readonly kind: "exact"; readonly id: string } | { readonly kind: "family"; readonly family: string }; -/** Loop tool-catalog capabilities for a concrete model. */ -export interface LoopModelCapabilities { - readonly acceptsComplexSchemas: boolean; - readonly acceptsLargeSchemas: boolean; - readonly serializesStateMutations: boolean; -} - /** * A model or provider whose request handling differs from the permissive * default, with the evidence for it. Entries exist to prevent a request the @@ -66,8 +59,9 @@ export interface LoopModelQuirk { * this model", not "may this model run" — every model pi-ai carries runs, with * Loop's own CDP browser tools. * - * Anthropic is absent deliberately: its native surfaces are version-gated in - * `providers/anthropic/capabilities.ts`, which {@link computerUseNativeSurfaces} reads. + * Anthropic is absent deliberately: its native surfaces are version-gated by + * the tool declarations themselves (`supportsAnthropicNative*` in core/tools), + * which {@link computerUseNativeSurfaces} reads. */ export const COMPUTER_USE_NATIVE_SURFACES: readonly { readonly provider: LoopProvider; @@ -242,6 +236,19 @@ export function providerForModel(model: Model): LoopProvider { return model.provider; } +/** Whether pi can defer loading this model's ordinary function tools. */ +export function modelSupportsDeferredTools(model: Model): boolean { + const compat = isRecord(model.compat) ? model.compat : undefined; + if (model.provider === "openai") return compat?.supportsToolSearch === true; + if (model.provider !== "anthropic" || model.id.toLowerCase().includes("haiku")) return false; + if (typeof compat?.supportsToolReferences === "boolean") return compat.supportsToolReferences; + const version = model.id.toLowerCase().match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/); + if (!version) return false; + const major = Number(version[1]); + const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0; + return major > 4 || (major === 4 && minor >= 5); +} + /** Provider-native tool surfaces available for a model, if any. */ export function computerUseNativeSurfaces(model: Model): readonly ComputerUseNativeSurface[] { if (model.provider === "anthropic") { @@ -277,6 +284,19 @@ export function loopModelQuirks(model: Model): readonly LoopModelQuirk[] { ); } +/** + * The availability facts core's compiler and menu consult for a model: + * request-shape capabilities and native tool surfaces. This is the seam that + * keeps provider capability lookup on the pi side of the boundary — core + * reads the supplied facts and never performs the lookup itself. + */ +export function loopModelFacts(model: Model): LoopModelFacts { + return { + capabilities: loopModelCapabilities(model), + nativeSurfaces: computerUseNativeSurfaces(model), + }; +} + function matchesModelId(modelId: string, match: LoopModelMatch): boolean { const id = modelId.toLowerCase(); return match.kind === "exact" ? id === match.id.toLowerCase() : isFamilyMatch(id, match.family.toLowerCase()); @@ -299,3 +319,7 @@ function compareLoopModels(a: LoopModelInfo, b: LoopModelInfo): number { if (a.provider !== b.provider) return a.provider.localeCompare(b.provider); return a.model.localeCompare(b.model); } + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/packages/loop/src/pi/providers/anthropic/adaptive-thinking.ts b/packages/loop/src/pi/providers/anthropic/adaptive-thinking.ts index c71230f..c420bf6 100644 --- a/packages/loop/src/pi/providers/anthropic/adaptive-thinking.ts +++ b/packages/loop/src/pi/providers/anthropic/adaptive-thinking.ts @@ -1,17 +1,16 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import type { LoopPayloadHook } from "../common"; +import type { LoopModelIdentity } from "../../../core/model-info"; /** Convert manual thinking budgets to Anthropic adaptive-thinking effort. */ -export const anthropicAdaptiveThinkingOnPayload: LoopPayloadHook = (payload, model) => { +export function anthropicAdaptiveThinkingOnPayload(payload: unknown, model: LoopModelIdentity): unknown { if (!isAdaptiveThinkingModel(model) || !isRecord(payload)) return undefined; const thinking = payload.thinking; if (!isRecord(thinking) || thinking.type !== "enabled") return undefined; const outputConfig = isRecord(payload.output_config) ? { ...payload.output_config } : {}; outputConfig.effort = effortFromBudgetTokens(thinking.budget_tokens); return { ...payload, thinking: { type: "adaptive" }, output_config: outputConfig }; -}; +} -function isAdaptiveThinkingModel(model: Model): boolean { +function isAdaptiveThinkingModel(model: LoopModelIdentity): boolean { if (model.provider !== "anthropic") return false; const id = model.id.toLowerCase(); return [ diff --git a/packages/loop/src/pi/providers/google/provider.ts b/packages/loop/src/pi/providers/google/provider.ts index 4c4ddbd..ecb08c8 100644 --- a/packages/loop/src/pi/providers/google/provider.ts +++ b/packages/loop/src/pi/providers/google/provider.ts @@ -12,14 +12,14 @@ import { type ThinkingLevel, type ToolCall, } from "@earendil-works/pi-ai"; -import type { LoopIncomingToolPlan } from "../../../core/tool-catalog"; +import { GOOGLE_INTERACTIONS_API, type LoopIncomingToolPlan } from "../../../core/tool-catalog"; import { responseThreadingDelta, responseThreadingEnabled, type ResponseThreadingOptions, } from "../common"; -export const GOOGLE_INTERACTIONS_API = "google-interactions"; +export { GOOGLE_INTERACTIONS_API }; const GOOGLE_NATIVE_ALIASES: Readonly> = Object.freeze({ "screenshot:take_screenshot": "take_screenshot", diff --git a/packages/loop/src/pi/providers/openai/provider.ts b/packages/loop/src/pi/providers/openai/provider.ts index 70ec88f..94f8325 100644 --- a/packages/loop/src/pi/providers/openai/provider.ts +++ b/packages/loop/src/pi/providers/openai/provider.ts @@ -22,11 +22,10 @@ import { import { createGrammarToolInputProperties } from "@earendil-works/pi-ai/api/constrained-sampling"; import { clampOpenAIPromptCacheKey } from "@earendil-works/pi-ai/api/openai-prompt-cache"; import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options"; -import type { LoopIncomingToolPlan } from "../../../core/tool-catalog"; +import { OPENAI_COMPUTER_USE_API, type LoopIncomingToolPlan } from "../../../core/tool-catalog"; import type { LoopSimpleStreamOptions } from "../common"; -/** Loop-owned api id for OpenAI's native computer tool, derived onto the model by compileLoopToolCatalog when that tool is selected. */ -export const OPENAI_COMPUTER_USE_API = "openai-computer-use"; +export { OPENAI_COMPUTER_USE_API }; /** * 64x64 black PNG, used when a computer action produced no screenshot so the diff --git a/packages/loop/src/core/tool-manager.ts b/packages/loop/src/pi/tool-manager.ts similarity index 70% rename from packages/loop/src/core/tool-manager.ts rename to packages/loop/src/pi/tool-manager.ts index e594552..f2926c6 100644 --- a/packages/loop/src/core/tool-manager.ts +++ b/packages/loop/src/pi/tool-manager.ts @@ -1,6 +1,7 @@ import type { AgentHarnessTool, AgentTool } from "@earendil-works/pi-agent-core"; import type { Api, Model } from "@earendil-works/pi-ai"; -import { getLoopModel, type LoopModelRef } from "../pi/models"; +import { loopModelPreparationTransforms } from "./catalog"; +import { getLoopModel, loopModelFacts, type LoopModelRef } from "./models"; import { callerToolIdentity, compileLoopToolCatalog, @@ -8,8 +9,8 @@ import { type LoopCatalogToolInput, type LoopToolCatalog, type LoopToolSpec, -} from "./tool-catalog"; -import { LoopExecutionResources } from "./resources"; +} from "../core/tool-catalog"; +import { LoopExecutionResources, type LoopExecutableTool } from "../core/resources"; /** * Caller-owned tool: a declarative Loop spec materialized by this package, or an @@ -35,7 +36,7 @@ export type LoopHarnessTool = LoopT * live catalog underneath it. */ export class LoopToolManager = LoopAgentTool> { - readonly catalog: LoopToolCatalog; + readonly catalog: LoopToolCatalog>; private readonly executables: readonly (AgentTool | AgentHarnessTool)[]; private readonly specs: ReadonlyMap; @@ -60,9 +61,12 @@ export class LoopToolManager = LoopAgent executables.set(callerToolIdentity(tool.name), tool); } } + const resolved = typeof model === "string" ? resolveModel(model) : model; this.catalog = compileLoopToolCatalog({ - model: typeof model === "string" ? resolveModel(model) : model, + model: resolved, requestedTools: inputs, + facts: loopModelFacts(resolved), + preparation: loopModelPreparationTransforms(resolved), }); // Joined strictly by compiled identity, never by position. @@ -70,7 +74,7 @@ export class LoopToolManager = LoopAgent const executable = executables.get(entry.identity); if (!executable) throw new Error(`compiled catalog entry "${entry.identity}" has no matching requested tool`); executables.delete(entry.identity); - return isLoopToolSpec(executable) ? resources.materialize(executable) : (executable as AgentHarnessTool); + return isLoopToolSpec(executable) ? asAgentTool(resources.materialize(executable)) : (executable as AgentHarnessTool); }); if (executables.size > 0) { throw new Error(`requested tool(s) ${[...executables.keys()].join(", ")} missing from the compiled catalog`); @@ -93,3 +97,26 @@ export class LoopToolManager = LoopAgent return this.specs.get(identity); } } + +/** + * Cached per executable, and executables are cached per (pool, spec), so pi + * sees one stable `AgentTool` identity across every recompile of a pair. + */ +const agentToolAdapters = new WeakMap(); + +/** Adapt a neutral Loop executable to pi's `AgentTool` calling convention. */ +function asAgentTool(executable: LoopExecutableTool): AgentTool { + const cached = agentToolAdapters.get(executable); + if (cached) return cached; + const { declaration } = executable.spec; + const tool: AgentTool = { + name: executable.spec.name, + label: executable.spec.name, + description: declaration.description, + parameters: declaration.parameters, + executionMode: "sequential", + execute: (_toolCallId, input, signal) => executable.execute(input, signal), + }; + agentToolAdapters.set(executable, tool); + return tool; +} diff --git a/packages/loop/test/anthropic-payload.test.ts b/packages/loop/test/anthropic-payload.test.ts index c44ccfd..86239f8 100644 --- a/packages/loop/test/anthropic-payload.test.ts +++ b/packages/loop/test/anthropic-payload.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getLoopModel } from "../src/pi/models"; import { anthropicAdaptiveThinkingOnPayload } from "../src/pi/providers/anthropic/adaptive-thinking"; +import { compileLoopToolCatalog, loop } from "../src/index"; describe("anthropicAdaptiveThinkingOnPayload", () => { it("converts Sonnet 5 manual thinking to adaptive thinking with effort", () => { @@ -54,3 +55,21 @@ describe("anthropicAdaptiveThinkingOnPayload", () => { expect(effortFor(32_768)).toBe("xhigh"); }); }); + +describe("model preparation through the compiled catalog", () => { + it("compiles pi's Anthropic preparation transform into the payload plan", async () => { + const catalog = compileLoopToolCatalog({ model: "anthropic:claude-sonnet-5", requestedTools: [loop.tools.browser.snapshot()] }); + + expect(catalog.payload.transforms.map((transform) => transform.identity)) + .toContain("provider.anthropic.model-preparation"); + await expect(catalog.payload.apply({ thinking: { type: "enabled", budget_tokens: 8_192 } }, catalog.model)) + .resolves.toMatchObject({ thinking: { type: "adaptive" }, output_config: { effort: "medium" } }); + }); + + it("compiles no preparation transform for other providers", () => { + const catalog = compileLoopToolCatalog({ model: "openai:gpt-5.5", requestedTools: [loop.tools.browser.snapshot()] }); + + expect(catalog.payload.transforms.map((transform) => transform.identity)) + .not.toContain("provider.anthropic.model-preparation"); + }); +}); diff --git a/packages/loop/test/core-boundary.test.ts b/packages/loop/test/core-boundary.test.ts new file mode 100644 index 0000000..af50b6f --- /dev/null +++ b/packages/loop/test/core-boundary.test.ts @@ -0,0 +1,91 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC_DIR = resolve(__dirname, "../src"); +const CORE_DIR = join(SRC_DIR, "core"); + +/** + * Bare specifiers core may import. Everything else — pi packages, this + * package's own entry points (`@onkernel/loop`, `@onkernel/loop/pi`), any + * future alias — is rejected by default rather than by enumeration. + */ +const ALLOWED_BARE_IMPORTS = [/^node:/, /^typebox(\/|$)/, /^@onkernel\/sdk(\/|$)/, /^sharp(\/|$)/]; + +/** + * Statement-anchored so template-literal contents cannot false-positive: + * import/export-from declarations (single- or multi-line), side-effect + * imports, dynamic import(), and require(). + */ +const IMPORT_SPECIFIERS = [ + /^(?:import|export)\b[^;"'`]*?from\s*["']([^"']+)["']/gm, + /^import\s*["']([^"']+)["']/gm, + /(? { + const path = join(dir, entry.name); + if (entry.isDirectory()) return coreFiles(path); + return entry.name.endsWith(".ts") ? [path] : []; + }); +} + +function importsOf(file: string): string[] { + const source = readFileSync(file, "utf8"); + return IMPORT_SPECIFIERS.flatMap((pattern) => [...source.matchAll(pattern)].map((match) => match[1]!)); +} + +function isAllowedCoreImport(specifier: string, file: string): boolean { + if (specifier.startsWith(".")) return isInsideCore(resolve(dirname(file), specifier)); + return ALLOWED_BARE_IMPORTS.some((allowed) => allowed.test(specifier)); +} + +function isInsideCore(path: string): boolean { + const rel = relative(CORE_DIR, path); + return rel !== ".." && !rel.startsWith(`..${sep}`); +} + +/** + * The neutral boundary: src/core must be expressible without pi in the room. + * Nothing under it may import a pi package — even type-only — or reach the pi + * binding through any specifier: relative (`../pi`), the package's own public + * subpaths (`@onkernel/loop/pi`), or an unlisted bare import. + */ +describe("core boundary", () => { + const files = coreFiles(CORE_DIR); + + it("walks a non-empty core tree", () => { + expect(files.length).toBeGreaterThan(10); + }); + + it("imports nothing from pi, at runtime or in types", () => { + const violations: string[] = []; + for (const file of files) { + for (const specifier of importsOf(file)) { + if (!isAllowedCoreImport(specifier, file)) { + violations.push(`${relative(SRC_DIR, file)} imports "${specifier}"`); + } + } + } + expect(violations).toEqual([]); + }); + + it("rejects pi packages, self-package aliases, and escapes from src/core", () => { + const file = join(CORE_DIR, "tools.ts"); + for (const forbidden of [ + "@earendil-works/pi-ai", + "@earendil-works/pi-agent-core", + "@onkernel/loop", + "@onkernel/loop/pi", + "../pi/models", + "../pi-extension/selection", + "..", + ]) { + expect(isAllowedCoreImport(forbidden, file), forbidden).toBe(false); + } + for (const allowed of ["typebox", "node:fs", "@onkernel/sdk", "@onkernel/sdk/resources/browsers", "./tool-catalog", "./actions/index"]) { + expect(isAllowedCoreImport(allowed, file), allowed).toBe(true); + } + }); +}); diff --git a/packages/loop/test/resources.test.ts b/packages/loop/test/resources.test.ts index a43b79d..65746dd 100644 --- a/packages/loop/test/resources.test.ts +++ b/packages/loop/test/resources.test.ts @@ -81,7 +81,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { const { resources, batches } = setup(); const spec = loop.tools.computer.batch({ actions: ["click", "screenshot", "keypress"] }); const tool = resources.materialize(spec); - const result = await tool.execute("batch", { actions: [ + const result = await tool.execute({ actions: [ { action: "click", x: 10, y: 20 }, { action: "screenshot" }, { action: "keypress", keys: ["Enter"] }, @@ -98,7 +98,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { const { resources } = setup({ failBatch: true }); const spec = loop.tools.computer.batch({ actions: ["screenshot", "click", "url", "cursor_position"] }); const tool = resources.materialize(spec); - const result = await tool.execute("batch", { actions: [ + const result = await tool.execute({ actions: [ { action: "screenshot" }, { action: "click", x: 10, y: 20 }, { action: "url" }, @@ -115,7 +115,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { const { resources, executed } = setup(); const spec = loop.tools.browser.batch({ actions: ["snapshot", "click", "text"] }); const tool = resources.materialize(spec); - const result = await tool.execute("browser-batch", { actions: [ + const result = await tool.execute({ actions: [ { action: "snapshot" }, { action: "click", ref: "e1" }, { action: "text" }, @@ -130,7 +130,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("replaces prior screenshots when a semantic browser batch condition fails", async () => { const { resources } = setup(); const spec = loop.tools.browser.batch({ actions: ["screenshot", "wait_for"] }); - const result = await resources.materialize(spec).execute("browser-batch", { actions: [ + const result = await resources.materialize(spec).execute({ actions: [ { action: "screenshot" }, { action: "wait_for", expect: { type: "text", text: "Ready" } }, ] }); @@ -147,7 +147,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { ["browser_act", loop.tools.browser.act(), { steps: [{ type: "wait" }] }], ] as const)("marks a failed standalone %s result as an error", async (_name, spec, input) => { const { resources } = setup(); - const result = await resources.materialize(spec).execute("standalone", input); + const result = await resources.materialize(spec).execute(input); expect(result.details).toMatchObject({ statusText: "Actions stopped before completion.", isError: true, @@ -161,7 +161,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("keeps a worked browser_act navigation boundary successful", async () => { const { resources } = setup({ successfulAct: true }); - const result = await resources.materialize(loop.tools.browser.act()).execute("act", { + const result = await resources.materialize(loop.tools.browser.act()).execute({ steps: [{ type: "click", x: 10, y: 20 }], }); expect(result.details).toMatchObject({ statusText: "Actions executed successfully." }); @@ -171,17 +171,17 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("shares one lazy browser executor across independently materialized tools", async () => { const { resources, createBrowserExecutor } = setup(); - await resources.materialize(loop.tools.browser.snapshot()).execute("snapshot", {}); - await resources.materialize(loop.tools.browser.click()).execute("click", { ref: "e1" }); + await resources.materialize(loop.tools.browser.snapshot()).execute({}); + await resources.materialize(loop.tools.browser.click()).execute({ ref: "e1" }); expect(createBrowserExecutor).toHaveBeenCalledTimes(1); }); it("returns status text for writes without capturing screenshots", async () => { const { resources, captureScreenshot, browserScreenshot } = setup(); - const click = await resources.materialize(loop.tools.browser.click()).execute("click", { ref: "e1" }); + const click = await resources.materialize(loop.tools.browser.click()).execute({ ref: "e1" }); expect(click.content).toEqual([{ type: "text", text: "Actions executed successfully." }]); - const navigate = await resources.materialize(loop.tools.browser.navigate()).execute("navigate", { url: "https://example.test" }); + const navigate = await resources.materialize(loop.tools.browser.navigate()).execute({ url: "https://example.test" }); expect(navigate.content).toEqual([{ type: "text", text: "Navigated" }]); expect(captureScreenshot).not.toHaveBeenCalled(); expect(browserScreenshot).not.toHaveBeenCalled(); @@ -189,7 +189,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("keeps Playwright execution failures as model-readable content", async () => { const { resources } = setup({ failPlaywright: true }); - const result = await resources.materialize(loop.tools.playwright()).execute("playwright", { code: "throw new Error('boom')" }); + const result = await resources.materialize(loop.tools.playwright()).execute({ code: "throw new Error('boom')" }); expect(result.content).toEqual([ { type: "text", text: "stderr:\ntrace" }, { type: "text", text: "error: page evaluation failed" }, @@ -205,7 +205,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("returns status text for provider-native writes without capturing a screenshot", async () => { const { resources, captureScreenshot } = setup(); const spec = loop.providers.google.toolsets.browser().find((tool) => tool.name === "click")!; - const result = await resources.materialize(spec).execute("click", { x: 100, y: 200 }); + const result = await resources.materialize(spec).execute({ x: 100, y: 200 }); expect(result.content).toEqual([{ type: "text", text: "Actions executed successfully." }]); expect(captureScreenshot).not.toHaveBeenCalled(); }); @@ -213,7 +213,7 @@ describe("LoopExecutionResources results and batch boundaries", () => { it("returns a screenshot for every OpenAI native computer action", async () => { const { resources, captureScreenshot } = setup(); const result = await resources.materialize(loop.providers.openai.tools.computer()) - .execute("computer", { action: { type: "click", x: 10, y: 20 } }); + .execute({ action: { type: "click", x: 10, y: 20 } }); // OpenAI's computer-use loop expects one screenshot back per computer_call; // without it the model is blind after each action. expect(result.content.some((block) => block.type === "image")).toBe(true); diff --git a/packages/loop/test/tool-manager.test.ts b/packages/loop/test/tool-manager.test.ts index c9f5c86..8d21d84 100644 --- a/packages/loop/test/tool-manager.test.ts +++ b/packages/loop/test/tool-manager.test.ts @@ -8,7 +8,7 @@ import { LoopExecutionResources, } from "../src/index"; import type Kernel from "@onkernel/sdk"; -import { LoopToolManager } from "../src/core/tool-manager"; +import { LoopToolManager } from "../src/pi/tool-manager"; const browser = { session_id: "browser_123", viewport: { width: 1440, height: 900 } } as KernelBrowser; const client = {} as Kernel; @@ -112,4 +112,21 @@ describe("LoopToolManager materialization", () => { expect(spy.mock.calls.length).toBeGreaterThan(1); expect(new Set(spy.mock.results.map((result) => result.value)).size).toBe(1); }); + + it("adapts a materialized spec to one stable pi AgentTool across recompiles", () => { + const resources = setup(); + const spec = loop.tools.browser.snapshot(); + + const [first] = new LoopToolManager(resources, "openai:gpt-5.5", [spec]).agentTools(); + const [second] = new LoopToolManager(resources, "openai:gpt-5.6-sol", [spec]).agentTools(); + + expect(first).toBe(second); + expect(first).toMatchObject({ + name: spec.name, + label: spec.name, + description: spec.declaration.description, + parameters: spec.declaration.parameters, + executionMode: "sequential", + }); + }); });