Skip to content

Commit 5a20110

Browse files
committed
Move deferred-tool and Anthropic availability decisions to the pi binding
Remaining availability interpretation leaves core: - modelSupportsDeferredTools moves to pi/models.ts: it reads pi compat flags (supportsToolSearch, supportsToolReferences) and provider model families, which is the binding's knowledge. The '.' surface re-exports it from pi. - The Anthropic native version gates return to pi/providers/anthropic/capabilities.ts. Core's tool namespace no longer carries an availability helper; the published loop namespace is composed in pi/loop.ts, which layers loop.providers.anthropic.supports over core's declarations. Core's compiler already consumes native-surface availability only through LoopModelFacts. - LoopCatalogModel drops the now-unused compat field; the neutral view is identity plus transport. Tests: 426 passed.
1 parent c4bd3a5 commit 5a20110

8 files changed

Lines changed: 74 additions & 43 deletions

File tree

‎docs/architecture.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ both explicitly and may use pi's orchestration primitives directly.
3131
- `./pi` (`src/pi/`) is the pi binding: `attach()`/`compile()`, the tool
3232
manager that joins compiled catalogs to executable pi `AgentTool`s, model
3333
resolution and availability facts (`compileLoopToolCatalog`/`loopToolMenu`
34-
accept provider-qualified refs here and supply `LoopModelFacts`), transport
35-
derivation, the provider adapters, provider retry, and header composition.
34+
accept provider-qualified refs here and supply `LoopModelFacts`,
35+
`modelSupportsDeferredTools` interprets pi compat flags, and the published
36+
`loop` namespace composes `loop.providers.anthropic.supports` over core's
37+
declarations), transport derivation, the provider adapters, provider retry,
38+
and header composition.
3639
- `src/pi-extension/` contributes these tools to a pi session that pi itself
3740
owns. It is the one consumer that uses neither `attach()` nor the harness: pi
3841
owns the model collection and the agent loop, so the extension takes the two

‎packages/loop/src/core/model-info.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ export interface LoopModelCapabilities {
1515
}
1616

1717
/**
18-
* Framework-neutral view of the model a catalog is compiled for: identity, the
19-
* transport it carries, and compat flags. A pi-ai `Model` satisfies this shape
20-
* structurally; core never sees more of it.
18+
* Framework-neutral view of the model a catalog is compiled for: identity and
19+
* the transport it carries. A pi-ai `Model` satisfies this shape structurally;
20+
* core never sees more of it.
2121
*/
2222
export interface LoopCatalogModel extends LoopModelIdentity {
2323
readonly api: string;
24-
readonly compat?: unknown;
2524
}
2625

2726
/**

‎packages/loop/src/core/tool-catalog.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -285,18 +285,6 @@ export function isLoopToolSpec(value: unknown): value is LoopToolSpec {
285285
return Boolean(value && typeof value === "object" && (value as { kind?: unknown }).kind === LOOP_TOOL_SPEC_KIND);
286286
}
287287

288-
export function modelSupportsDeferredTools(model: LoopCatalogModel): boolean {
289-
const compat = isRecord(model.compat) ? model.compat : undefined;
290-
if (model.provider === "openai") return compat?.supportsToolSearch === true;
291-
if (model.provider !== "anthropic" || model.id.toLowerCase().includes("haiku")) return false;
292-
if (typeof compat?.supportsToolReferences === "boolean") return compat.supportsToolReferences;
293-
const version = model.id.toLowerCase().match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
294-
if (!version) return false;
295-
const major = Number(version[1]);
296-
const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
297-
return major > 4 || (major === 4 && minor >= 5);
298-
}
299-
300288
function normalizeTool(tool: LoopCatalogToolInput): LoopCatalogEntryDraft {
301289
if (isLoopToolSpec(tool)) {
302290
const schemaFingerprint = stableStringify(tool.declaration.parameters);

‎packages/loop/src/core/tools.ts‎

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -292,26 +292,6 @@ function playwright(options: LoopToolNameOptions = {}): LoopToolSpec {
292292
});
293293
}
294294

295-
const ANTHROPIC_NATIVE_BROWSER_FAMILIES = ["claude-opus-4-8", "claude-opus-5", "claude-sonnet-5"] as const;
296-
const ANTHROPIC_NATIVE_COMPUTER_FAMILIES = ["claude-fable-5", ...ANTHROPIC_NATIVE_BROWSER_FAMILIES] as const;
297-
298-
/** Return whether an Anthropic model ID supports the July 2026 native browser tool. */
299-
export function supportsAnthropicNativeBrowser(modelId: string): boolean {
300-
return ANTHROPIC_NATIVE_BROWSER_FAMILIES.some((family) => anthropicModelFamily(modelId, family));
301-
}
302-
303-
/** Return whether an Anthropic model ID supports the July 2026 native computer tool. */
304-
export function supportsAnthropicNativeComputer(modelId: string): boolean {
305-
return ANTHROPIC_NATIVE_COMPUTER_FAMILIES.some((family) => anthropicModelFamily(modelId, family));
306-
}
307-
308-
function anthropicModelFamily(modelId: string, family: string): boolean {
309-
const id = modelId.toLowerCase();
310-
if (id === family) return true;
311-
if (!id.startsWith(`${family}-`)) return false;
312-
return id.slice(family.length + 1).split("-").every((segment) => /^\d+$/.test(segment));
313-
}
314-
315295
function anthropicNativeComputer(options: { version: "20260701"; enableZoom?: boolean; displayNumber?: number } = { version: "20260701" }): LoopToolSpec {
316296
if (options.version !== "20260701") throw new Error(`unsupported Anthropic native computer version "${String(options.version)}"`);
317297
const declaration = {
@@ -863,7 +843,6 @@ const providers = Object.freeze({
863843
openai: Object.freeze({ source: providerSources.openai, tools: Object.freeze({ computer: openaiNativeComputer }) }),
864844
anthropic: Object.freeze({
865845
source: providerSources.anthropic,
866-
supports: Object.freeze({ browser: supportsAnthropicNativeBrowser }),
867846
tools: Object.freeze({ computer: anthropicNativeComputer, browser: anthropicNativeBrowser }),
868847
}),
869848
google: Object.freeze({
@@ -872,7 +851,11 @@ const providers = Object.freeze({
872851
}),
873852
});
874853

875-
/** Frozen, discoverable tool namespace of every tool this package declares. */
854+
/**
855+
* Frozen, discoverable tool namespace of every tool this package declares.
856+
* The published `loop` on the package root is this namespace with the pi
857+
* binding's availability helpers composed in (`loop.providers.anthropic.supports`).
858+
*/
876859
export const loop = Object.freeze({
877860
coordinates: Object.freeze({ pixels: () => pixels, normalized }),
878861
tools: Object.freeze({ browser: browserTools, computer: computerTools, playwright }),

‎packages/loop/src/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ export * from "./core/tools";
66
export { normalizeGotoUrl } from "./core/url";
77

88
// pi-flavored entry points: these shadow the star exports above, keeping
9-
// provider-qualified model refs ("openai:gpt-5.5") working on this surface
10-
// while src/core stays free of pi model resolution.
9+
// provider-qualified model refs ("openai:gpt-5.5") and pi-supplied model
10+
// availability working on this surface while src/core stays free of both.
1111
export { compileLoopToolCatalog, loopToolMenu } from "./pi/catalog";
12+
export { loop, type LoopNamespace } from "./pi/loop";
13+
export { modelSupportsDeferredTools } from "./pi/models";
1214

1315
export type { LoopAgentTool, LoopHarnessTool } from "./pi/tool-manager";
1416
export { LoopExecutionResources } from "./core/resources";

‎packages/loop/src/pi/loop.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { loop as coreLoop } from "../core/tools";
2+
import { supportsAnthropicNativeBrowser } from "./providers/anthropic/capabilities";
3+
4+
/**
5+
* The published tool namespace: core's declarations with the pi binding's
6+
* per-model availability helpers composed in. Availability decisions live on
7+
* the pi side of the boundary; core only declares the tools.
8+
*/
9+
export const loop = Object.freeze({
10+
...coreLoop,
11+
providers: Object.freeze({
12+
...coreLoop.providers,
13+
anthropic: Object.freeze({
14+
...coreLoop.providers.anthropic,
15+
supports: Object.freeze({ browser: supportsAnthropicNativeBrowser }),
16+
}),
17+
}),
18+
});
19+
20+
export type LoopNamespace = typeof loop;

‎packages/loop/src/pi/models.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Api, Model } from "@earendil-works/pi-ai";
22
import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
33
import type { ComputerUseNativeSurface, LoopModelCapabilities, LoopModelFacts } from "../core/model-info";
4-
import { supportsAnthropicNativeBrowser, supportsAnthropicNativeComputer } from "../core/tools";
4+
import { supportsAnthropicNativeBrowser, supportsAnthropicNativeComputer } from "./providers/anthropic/capabilities";
55

66
export type { ComputerUseNativeSurface, LoopModelCapabilities } from "../core/model-info";
77

@@ -236,6 +236,19 @@ export function providerForModel(model: Model<Api>): LoopProvider {
236236
return model.provider;
237237
}
238238

239+
/** Whether pi can defer loading this model's ordinary function tools. */
240+
export function modelSupportsDeferredTools(model: Model<Api>): boolean {
241+
const compat = isRecord(model.compat) ? model.compat : undefined;
242+
if (model.provider === "openai") return compat?.supportsToolSearch === true;
243+
if (model.provider !== "anthropic" || model.id.toLowerCase().includes("haiku")) return false;
244+
if (typeof compat?.supportsToolReferences === "boolean") return compat.supportsToolReferences;
245+
const version = model.id.toLowerCase().match(/^claude-(?:opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
246+
if (!version) return false;
247+
const major = Number(version[1]);
248+
const minor = version[2] && version[2].length < 8 ? Number(version[2]) : 0;
249+
return major > 4 || (major === 4 && minor >= 5);
250+
}
251+
239252
/** Provider-native tool surfaces available for a model, if any. */
240253
export function computerUseNativeSurfaces(model: Model<Api>): readonly ComputerUseNativeSurface[] {
241254
if (model.provider === "anthropic") {
@@ -306,3 +319,7 @@ function compareLoopModels(a: LoopModelInfo, b: LoopModelInfo): number {
306319
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
307320
return a.model.localeCompare(b.model);
308321
}
322+
323+
function isRecord(value: unknown): value is Record<string, unknown> {
324+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
325+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const NATIVE_BROWSER_FAMILIES = ["claude-opus-4-8", "claude-opus-5", "claude-sonnet-5"] as const;
2+
const NATIVE_COMPUTER_FAMILIES = ["claude-fable-5", ...NATIVE_BROWSER_FAMILIES] as const;
3+
4+
/** Return whether an Anthropic model ID supports the July 2026 native browser tool. */
5+
export function supportsAnthropicNativeBrowser(modelId: string): boolean {
6+
return NATIVE_BROWSER_FAMILIES.some((family) => modelFamily(modelId, family));
7+
}
8+
9+
/** Return whether an Anthropic model ID supports the July 2026 native computer tool. */
10+
export function supportsAnthropicNativeComputer(modelId: string): boolean {
11+
return NATIVE_COMPUTER_FAMILIES.some((family) => modelFamily(modelId, family));
12+
}
13+
14+
function modelFamily(modelId: string, family: string): boolean {
15+
const id = modelId.toLowerCase();
16+
if (id === family) return true;
17+
if (!id.startsWith(`${family}-`)) return false;
18+
return id.slice(family.length + 1).split("-").every((segment) => /^\d+$/.test(segment));
19+
}

0 commit comments

Comments
 (0)