blob: d5d878edda767db8848c2692a5674ac43ff80d14 [file]
import type { ToolAvailabilityDiagnostic } from '@maka/core/usage-stats/types';
import { z } from 'zod';
import { estimateTokens } from './context-budget-helpers.js';
import { canonicalizeToolSet, toolSchemaCharsForDiagnostics } from './request-shape.js';
import type { MakaTool, ToolGating } from './tool-runtime.js';
/**
* Unified tool-availability mechanism (issue #37): one catalog, one connector,
* one same-turn activation policy, one diagnostics source. Subsumes the former
* deferred-loader (PR #30) and tool-source-economy (PR #34) into a single
* runtime whose only knob is the global `economy` switch.
*
* - `economy: false` (or no hideable groups) → every tool is advertised every
* turn; no connector, no gating, no diagnostics (the full-surface case).
* - `economy: true` → only ungrouped tools are visible; each group's
* tools are withheld until the model activates the group via `load_tools`,
* which takes effect in the next Runtime request projection. Activations persist
* across turns by re-seeding from the RuntimeEvent ledger.
*/
/** Canonical name of the always-on group-activation connector. */
export const LOAD_TOOLS_NAME = 'load_tools';
/**
* Historical connector names accepted ONLY when re-seeding prior-turn
* activations from the durable ledger, so sessions that activated groups under
* the pre-unification connectors (`load_tool` from PR #30, `connect_tool_source`
* from PR #34) do not regress. Same-turn activation never honors these — only
* `LOAD_TOOLS_NAME` is a live connector. Never exposed as a provider-visible tool.
*/
const SEED_CONNECTOR_NAMES: ReadonlySet<string> = new Set([
LOAD_TOOLS_NAME,
'load_tool',
'connect_tool_source',
]);
/** A natural cluster of tools that load together (browser, computer use, …). */
export interface ToolGroup {
id: string;
toolNames: readonly string[];
label?: string;
description?: string;
}
export interface ToolAvailabilityConfig {
/** `true` = only ungrouped tools are visible, groups load on demand; `false` = all visible. */
economy: boolean;
/** Natural clusters hidden behind the connector when economy is on. */
groups?: readonly ToolGroup[];
}
/** The minimal shape this module reads from an AI SDK `StepResult`. */
export interface StepLike {
toolCalls?: ReadonlyArray<{ toolName: string; input?: unknown }>;
}
/** The minimal shape this module reads from a durable `RuntimeEvent`. */
export interface RuntimeEventLike {
content?: { kind?: string; name?: string; args?: unknown } | undefined;
}
/**
* Everything the backend needs for one turn. Produced by `prepare()`, which
* seeds prior-turn activations from the ledger and wires same-turn activation.
*/
export interface ToolAvailabilityPlan {
/** Full dispatch set (sorted visible tools + the repair fallback). */
providerTools: MakaTool[];
/** Step-0 model-visible active subset. */
activeTools: string[];
/** Recomputes the active subset from completed provider steps. */
projectActiveTools?: (options: { completedSteps?: ReadonlyArray<StepLike> }) => {
activeTools: string[];
};
/** Tool names the repair path matches against; tracks the current step's snapshot. */
currentRepairToolNames: () => string[];
/** Execute-boundary gating; undefined in full mode. */
gating?: ToolGating;
/** Diagnostic for a given active set + measured visible schema chars; undefined in full mode. */
diagnostics: (
activeTools: readonly string[],
visibleToolSchemaChars: number,
) => ToolAvailabilityDiagnostic | undefined;
}
interface CatalogGroup {
id: string;
/** Gated members only (core/unknown tools excluded). Sorted. */
toolNames: string[];
label?: string;
description?: string;
}
export class ToolAvailabilityRuntime {
private readonly economy: boolean;
private readonly groups: CatalogGroup[];
private readonly groupIds: Set<string>;
/** Tools that may be hidden this session (all group members). */
private readonly gatedNames: Set<string>;
/** Tools advertised on every step: ungrouped tools + the connector. */
private readonly alwaysActive: Set<string>;
private readonly connector?: MakaTool;
/** Real tools plus the connector (when present) — the canonicalize input. */
private readonly allTools: readonly MakaTool[];
constructor(
tools: readonly MakaTool[],
config: ToolAvailabilityConfig | undefined,
private readonly invalidTool: MakaTool,
) {
const known = new Set(tools.map((tool) => tool.name));
const groups: CatalogGroup[] = [];
const gatedNames = new Set<string>();
for (const group of config?.groups ?? []) {
if (!group.id) continue;
const members: string[] = [];
for (const name of group.toolNames) {
// Unknown tools are ignored; the first group to claim a tool owns it.
if (!known.has(name) || gatedNames.has(name)) continue;
gatedNames.add(name);
members.push(name);
}
if (members.length === 0) continue;
members.sort((a, b) => a.localeCompare(b));
groups.push({
id: group.id,
toolNames: members,
...(group.label !== undefined ? { label: group.label } : {}),
...(group.description !== undefined ? { description: group.description } : {}),
});
}
this.groups = groups;
this.groupIds = new Set(groups.map((group) => group.id));
this.gatedNames = gatedNames;
// Economy only bites when there is actually something to hide.
this.economy = (config?.economy ?? false) && gatedNames.size > 0;
this.connector = this.economy ? this.buildConnector() : undefined;
this.alwaysActive = new Set<string>([
...[...known].filter((name) => !gatedNames.has(name)),
...(this.connector ? [this.connector.name] : []),
]);
this.allTools = this.connector ? [...tools, this.connector] : tools;
}
prepare(
priorEvents: ReadonlyArray<RuntimeEventLike> | undefined,
requiredToolNames: ReadonlySet<string> = new Set(),
): ToolAvailabilityPlan {
const canonical = canonicalizeToolSet(this.allTools, this.invalidTool);
if (!this.economy) {
// Full surface: every visible tool is active, nothing is gated.
return {
providerTools: canonical.providerTools,
activeTools: canonical.activeTools,
currentRepairToolNames: () => canonical.activeTools,
diagnostics: () => undefined,
};
}
const seedGroups = this.seedLoadedGroups(priorEvents);
const knownNames = new Set(canonical.providerTools.map((tool) => tool.name));
const requiredNames = [...requiredToolNames].filter((name) => knownNames.has(name));
// Turn-local snapshot the guard / repair / diagnostics read; recomputed
// before every provider request. No cross-turn mutable state — a load
// survives turns only via the ledger seed above (durable by construction),
// and within one send the backend's translation point hands every hook a
// send-global completed-step view spanning overflow-retry attempts, so activation
// stays monotonic per send without a bespoke set here.
const turn = { active: new Set<string>() };
const computeActive = (steps: ReadonlyArray<StepLike> | undefined): string[] => {
const loaded = new Set<string>([...seedGroups, ...this.loadedGroupsFromSteps(steps)]);
const activeNames = this.activeNamesFor(loaded);
for (const name of requiredNames) activeNames.add(name);
const active = canonicalizeToolSet(this.allTools, this.invalidTool, activeNames).activeTools;
turn.active = new Set(active);
return active;
};
return {
providerTools: canonical.providerTools,
activeTools: computeActive(undefined),
projectActiveTools: ({ completedSteps }) => ({
activeTools: computeActive(completedSteps),
}),
currentRepairToolNames: () => [...turn.active],
gating: { gatedNames: this.gatedNames, activeNames: () => turn.active },
diagnostics: (active, chars) => this.buildDiagnostic(active, chars),
};
}
// ── catalog helpers ───────────────────────────────────────────────────────
private activeNamesFor(loadedGroupIds: ReadonlySet<string>): Set<string> {
const active = new Set<string>(this.alwaysActive);
for (const group of this.groups) {
if (loadedGroupIds.has(group.id)) {
for (const name of group.toolNames) active.add(name);
}
}
return active;
}
private seedLoadedGroups(events: ReadonlyArray<RuntimeEventLike> | undefined): Set<string> {
const out = new Set<string>();
for (const event of events ?? []) {
const content = event?.content;
if (
!content ||
content.kind !== 'function_call' ||
!SEED_CONNECTOR_NAMES.has(content.name ?? '')
)
continue;
// Ledger seeding reads the group id from any historical arg key.
const id = extractGroupId(content.args);
if (id && this.groupIds.has(id)) out.add(id);
}
return out;
}
private loadedGroupsFromSteps(steps: ReadonlyArray<StepLike> | undefined): Set<string> {
const out = new Set<string>();
for (const step of steps ?? []) {
for (const call of step.toolCalls ?? []) {
// Same-turn activation is the unified connector only. The historical
// names are accepted for ledger seeding (prior turns), never live this
// turn — and only the `group` arg is honored here.
if (call?.toolName !== LOAD_TOOLS_NAME) continue;
const id = extractGroupId(call.input, ['group']);
if (id && this.groupIds.has(id)) out.add(id);
}
}
return out;
}
private buildConnector(): MakaTool<{ group: string }, { loaded: string[] }> {
// Only reached when economy is on, which requires at least one gated group,
// so `ids` is always non-empty — a plain enum, no empty fallback.
const ids = this.groups.map((group) => group.id);
const groupSchema = z.enum(ids as [string, ...string[]]);
return {
name: LOAD_TOOLS_NAME,
description: renderCatalog(this.groups),
parameters: z.object({
group: groupSchema.describe('The capability group to load.'),
}),
impl: ({ group }: { group: string }) => {
const found = this.groups.find((candidate) => candidate.id === group);
if (!found) {
throw new Error(`Unknown tool group "${group}". Available: ${ids.join(', ')}.`);
}
return { loaded: [...found.toolNames] };
},
};
}
private buildDiagnostic(
active: readonly string[],
visibleToolSchemaChars: number,
): ToolAvailabilityDiagnostic {
const activeSet = new Set(active);
const isLoaded = (group: CatalogGroup): boolean =>
group.toolNames.every((name) => activeSet.has(name));
const enabledSourceIds = this.groups
.filter(isLoaded)
.map((group) => group.id)
.sort((a, b) => a.localeCompare(b));
const availableSourceIds = this.groups
.filter((group) => !isLoaded(group))
.map((group) => group.id)
.sort((a, b) => a.localeCompare(b));
const full = canonicalizeToolSet(this.allTools, this.invalidTool);
const fullToolSchemaChars = toolSchemaCharsForDiagnostics(full.providerTools, full.activeTools);
const toolSchemaCharReduction = Math.max(0, fullToolSchemaChars - visibleToolSchemaChars);
return {
mode: 'economy',
enabledSourceIds,
availableSourceIds,
connectorToolName: LOAD_TOOLS_NAME,
visibleToolNamesBySource: groupToolNamesById(this.groups),
visibleToolCount: active.length,
fullToolCount: full.activeTools.length,
// active and full both count the connector, so the difference is exactly
// the hidden (unloaded group) tools — keeps full = visible + hidden.
hiddenToolCount: Math.max(0, full.activeTools.length - active.length),
visibleToolSchemaChars,
fullToolSchemaChars,
toolSchemaCharReduction,
estimatedToolSchemaTokenReduction: estimateTokens(toolSchemaCharReduction),
};
}
}
function extractGroupId(
input: unknown,
keys: readonly string[] = ['group', 'namespace', 'source'],
): string | undefined {
let value = input;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
return undefined;
}
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
for (const key of keys) {
if (typeof record[key] === 'string') return record[key] as string;
}
}
return undefined;
}
function renderCatalog(groups: readonly CatalogGroup[]): string {
const lines = groups.map(
(group) => `- ${group.id}: ${group.description ?? group.label ?? group.toolNames.join(', ')}`,
);
return [
'Load additional tool groups on demand. These capabilities exist but their full',
'parameter schemas are withheld to keep each turn lean. Call load_tools with a',
'group id; the tools it returns become callable on your next step.',
'',
'Available groups:',
...lines,
].join('\n');
}
function groupToolNamesById(groups: readonly CatalogGroup[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const group of [...groups].sort((a, b) => a.id.localeCompare(b.id))) {
out[group.id] = [...group.toolNames].sort((a, b) => a.localeCompare(b));
}
return out;
}