Registration policy, lifecycle binding, and content projection
The policy implementation follows.
ts
export class ToolLifecycleRegistry<
Types extends ToolRuntimeTypes,
Declaration extends ToolRegistration<Types> = ToolRegistration<Types>,
> {
private readonly tools: ToolRegistry<PreparedTool<Types, Declaration>>;
private readonly registered = new Map<string, PreparedTool<Types, Declaration>>();
private readonly sources = new Map<string, Declaration>();
/** Tools that own an invocation id; dispatch by decision next reads this index. */
private readonly invocations = new Map<string, PreparedTool<Types, Declaration>>();
/** Tools that answer a delegate id; dispatch by node reads this index. */
private readonly delegateIndex = new Map<string, PreparedTool<Types, Declaration>>();
private normalizer?: ToolNormalizer<Types>;
private finalizer?: ToolFinalizer<Types>;
private sealed = false;
private diagnostics?: ToolRegistryPorts['diagnostics'];
private activeDiagnostics?: ToolRegistryPorts['diagnostics'];
private readonly invocationPorts: ToolInvocationPorts<Types['context']>;
private readonly options: ToolsOptions<Types, Declaration>;
/**
* @param options - Receiver, execution context, error wording, registration policy, protocol
* providers and optional journal/receipt ports; contract methods are bound to their owner.
* @throws TypeError When no `goalIds` provider is supplied.
*/
constructor(options: ToolsOptions<Types, Declaration>) {
this.options = snapshotLifecycleOptions(options);
this.tools = new ToolRegistry({
execution: this.options.execution,
errors: this.options.errors,
});
this.diagnostics = this.options.execution?.diagnostics;
this.invocationPorts = Object.freeze({
logger: this.options.logger,
diagnostics: this.options.diagnostics ?? (() => this.diagnostics),
signal: this.options.signal,
journal: this.options.journal,
checkpoint: this.options.checkpoint,
receipt: this.options.receipt,
receiptError: this.options.receiptError,
});
}
/** Core tool events, forwarded unchanged. */
get events() {
return this.tools.events;
}
/** Swap the diagnostics sink for later invocations; the Core registry is updated as well. */
setDiagnostics(value: ToolRegistryPorts['diagnostics']): void {
this.diagnostics = value;
this.tools.setDiagnostics(value);
}
/**
* Register and freeze one tool definition.
*
* Handlers are bound to the original object; authorization, inspection and finalization
* hooks must return `void` synchronously, while the remaining handlers are checked for
* accidental thenables only.
*
* @param input - Tool definition with identity, audience, effect and its hooks.
* @returns This registry, for chaining.
* @throws A configured registration error for a sealed registry, invalid identity,
* description, audience, effect, goal ids, missing handlers, duplicate tool id,
* duplicate delegate id, incomplete lifecycle contract, protocol version mismatch or
* duplicate invocation id.
*/
register(input: Declaration): this {
const {
id,
kind,
description,
audience,
effect,
goalIds,
delegate,
lifecycle,
parseInput: inputParser,
parseOutput: outputParser,
active: availability,
execute,
debugSteps,
} = input;
const parseInput = inputParser?.bind(input),
parseOutput = outputParser?.bind(input);
const available = typeof availability === 'function' ? availability.bind(input) : availability;
const frozenGoalIds = Array.isArray(goalIds) ? Object.freeze([...goalIds]) : goalIds;
// Validate the declaration before projecting it into a model tool: a sealed or invalid
// registration must report its configured error instead of a schema-projection failure.
this.assertRegistration(input, { parseInput, parseOutput, execute });
const modelTool = new ModelToolRegistry([
{
id,
description,
inputSchema: input.inputSchema ?? { jsonSchema: { type: 'object' }, parse: parseInput },
resolve: input.resolve?.bind(input) ?? ((value: unknown) => ({ toolId: id, input: value })),
active: typeof input.active === 'function' ? input.active.bind(input) : input.active,
asIntent:
typeof input.asIntent === 'function' ? input.asIntent.bind(input) : input.asIntent,
},
]).require(id);
const tool = {
...registrationData(input, [
'id',
'kind',
'description',
'audience',
'effect',
'goalIds',
'delegate',
'lifecycle',
'parseInput',
'parseOutput',
'execute',
'debugSteps',
'active',
]),
...modelTool,
id,
kind,
description,
audience,
effect,
delegate,
lifecycle,
goalIds: frozenGoalIds,
parseInput,
parseOutput,
debugSteps: debugSteps?.bind(input),
execute: execute?.bind(input),
active: available,
};
const registered = Object.freeze({
...tool,
...(lifecycle ? { lifecycle: bindLifecycle(lifecycle) } : {}),
}) as unknown as PreparedTool<Types, Declaration>;
return this.install(input, registered, true);
}
/** Validate one declaration in the fixed order documented by `register`. */
private assertRegistration(
input: Declaration,
handlers: { parseInput: unknown; parseOutput: unknown; execute: unknown },
): void {
const policy = this.options.registration;
const identityPredicate = policy.isValidId;
const isValidId = () =>
booleanResult(
identityPredicate(input),
'Tool identity predicates must be synchronous',
'Tool identity predicates must return boolean',
);
const invalid = policy.invalidTool ?? 'Invalid registered tool';
const invalidInvocation = policy.invalidInvocation ?? 'Invalid or duplicate tool invocation';
const { lifecycle } = input;
// A lifecycle block may declare hooks only. Declaring any invocation part makes all three
// required, so a partial declaration fails instead of silently never running.
const ownsInvocation =
!!lifecycle &&
(lifecycle.id !== undefined ||
lifecycle.protocol !== undefined ||
lifecycle.call !== undefined);
const { parseInput, parseOutput, execute } = handlers;
breakWhile(
[
{ occur: () => this.sealed, error: 'Tool registry is sealed', tags: ['sealed'] },
{
occur: [() => !policy.kinds.includes(input.kind), () => !isValidId()],
error: invalid,
tags: ['identity'],
},
{
occur: () => typeof input.description !== 'string' || !input.description.trim(),
error: invalid,
tags: ['description'],
},
{
occur: () =>
input.active !== undefined &&
typeof input.active !== 'boolean' &&
typeof input.active !== 'function',
error: invalid,
tags: ['active'],
},
{
occur: [
() => !policy.audiences.includes(input.audience),
() => !policy.effects.includes(input.effect),
],
error: invalid,
tags: ['execution-contract'],
},
{
occur: [
() => input.goalIds !== 'all' && !Array.isArray(input.goalIds),
() => input.goalIds !== 'all' && !input.goalIds.length,
() =>
input.goalIds !== 'all' &&
input.goalIds.some(
(goal) =>
!booleanResult(
policy.goalExists(goal),
'Goal lookup must return boolean synchronously',
),
),
() => input.goalIds !== 'all' && new Set(input.goalIds).size !== input.goalIds.length,
],
error: invalid,
tags: ['goals'],
},
{
occur: () =>
![parseInput, parseOutput, execute].every((handler) => typeof handler === 'function'),
error: invalid,
tags: ['handlers'],
},
{
occur: () => this.tools.has(input.id),
error: () => new Error(`Duplicate registered tool: ${input.id}`),
tags: ['duplicate-name'],
},
{
occur: () => !!input.delegate && this.delegateIndex.has(input.delegate),
error: () => new Error(`Duplicate registered delegate: ${input.delegate}`),
tags: ['delegate', 'duplicate-id'],
},
{
occur: () => ownsInvocation && !lifecycle!.id,
error: invalidInvocation,
tags: ['lifecycle', 'identity'],
},
{
occur: () => ownsInvocation && typeof lifecycle!.call !== 'function',
error: invalidInvocation,
tags: ['lifecycle', 'contract'],
},
{
occur: () => ownsInvocation && typeof lifecycle!.protocol?.keys !== 'function',
error: invalidInvocation,
tags: ['lifecycle', 'contract'],
},
{
occur: () =>
ownsInvocation &&
policy.protocolVersion !== undefined &&
lifecycle!.protocol?.version !== undefined &&
lifecycle!.protocol.version !== policy.protocolVersion,
error: invalidInvocation,
tags: ['lifecycle', 'version'],
},
{
occur: () => !!lifecycle?.id && this.invocations.has(lifecycle.id),
error: invalidInvocation,
tags: ['lifecycle', 'duplicate-id'],
},
],
policy.tags,
);
}
private install(
input: Declaration,
tool: PreparedTool<Types, Declaration>,
validated = false,
): this {
if (!validated)
this.assertRegistration(input, {
parseInput: tool.parseInput,
parseOutput: tool.parseOutput,
execute: tool.execute,
});
this.tools.register(tool);
this.registered.set(tool.id, tool);
this.sources.set(tool.id, input);
if (tool.lifecycle?.id) this.invocations.set(tool.lifecycle.id, tool);
if (tool.delegate) this.delegateIndex.set(tool.delegate, tool);
return this;
}
/** Register every tool in iteration order. */
registerAll(tools: Iterable<Declaration>): this {
for (const tool of tools) this.register(tool);
return this;
}
/**
* Invoke one Core tool by its already-validated request.
*
* When the request carries no diagnostics, the currently active one is used, so a call made
* from inside a tool inherits that tool's diagnostic context.
*/
invoke(request: ToolInvocationRequest): ToolResult<unknown> {
const call = toolInvocation(request, arguments.length);
return this.tools.invoke({ ...call, diagnostics: call.diagnostics ?? this.activeDiagnostics });
}
/** Registered tools in registration order. */
get definitions(): readonly RegisteredToolDefinition<Types, Declaration>[] {
return Object.freeze([...this.registered.values()]);
}
/** Registered tool ids, delegated to the Core registry. */
ids() {
return this.tools.ids();
}
/** Iterate `[id, tool]` pairs. */
entries(): IterableIterator<[string, RegisteredToolDefinition<Types, Declaration>]> {
return this.registered.entries();
}
/** Registered tool for `id`, or `undefined` when it is absent. */
get(id: string): RegisteredToolDefinition<Types, Declaration> | undefined {
return this.registered.get(id);
}
/**
* Registered tool for `id`.
*
* @throws TypeError When the id is not registered.
*/
require(id: string): RegisteredToolDefinition<Types, Declaration> {
this.tools.require(id);
return this.registered.get(id)!;
}
/** Whether a tool id is registered. */
has(name: string): boolean {
return this.tools.has(name);
}
/** Iterate the registered tools. */
values(): IterableIterator<RegisteredToolDefinition<Types, Declaration>> {
return this.registered.values();
}
/** Whether a delegate id is claimed by a registered tool. */
hasDelegate(id: string): boolean {
return this.delegateIndex.has(id);
}
/**
* Install the single normalization handler.
*
* @throws An error when a handler is already installed or the registry is sealed, because a
* second handler would make normalization order ambiguous.
*/
registerNormalizer(handler: ToolNormalizer<Types>): void {
breakWhile(this.sealed || this.normalizer, 'Normalizer already registered or registry sealed');
this.normalizer = handler;
}
/** Apply the installed normalizer, or return the value unchanged when none exists. */
normalize(value: unknown, ...args: Types['normalizerArgs']): unknown {
return this.normalizer
? synchronous(
this.normalizer.call(this.options.receiver?.() ?? this, value, ...args),
'Tool normalization must be synchronous',
)
: value;
}
/**
* Install the single reply finalizer.
*
* @throws An error when a finalizer is already installed or the registry is sealed.
*/
registerFinalizer(handler: ToolFinalizer<Types>): void {
breakWhile(this.sealed || this.finalizer, 'Finalizer already registered or registry sealed');
this.finalizer = handler;
}
/**
* Run the installed finalizer over a decision.
*
* @throws An error when no finalizer is installed, or when it does not finish synchronously.
*/
finish(
receiver: Types['receiver'],
decision: Types['decision'],
context: Types['context'],
): Types['result'] {
breakWhile(!this.finalizer, 'No registered reply finalizer');
return synchronous(
this.finalizer!.call(receiver, receiver, decision, context),
'Tool finalization must be synchronous',
);
}
/**
* Run the invocation that answers one decision next.
*
* Delegation is the App's choice; this method only executes the tool that claimed the id, so a
* purely programmatic node, a model choice and a mixed pipeline all dispatch the same way.
*
* Diagnostics are swapped to the invocation's own sink for the duration of the call and
* restored afterwards, so nested tool calls report under the innermost scope.
*
* @param next - Decision next carrying the claimed delegate id in `type`.
* @param context - Execution context forwarded to Core's invocation executor.
* @throws A configured error when the delegate is unknown or the tool is unavailable.
*/
call(next: { type: string }, context: Types['context']): void {
const tool = this.delegateIndex.get(next.type) ?? this.invocations.get(next.type);
breakWhile([
{
occur: () => !tool?.lifecycle,
error: () =>
this.options.unavailableInvocation?.(next.type) ??
new Error(`Tool invocation is not registered: ${next.type}`),
},
{
occur: () => !toolActive(tool!.active, context),
error: () => this.options.forbidden?.() ?? new Error('Tool is not available'),
},
]);
executeInvocation(
{
id: tool!.id,
call: (input, executionContext, execution) => {
const previous = this.activeDiagnostics;
this.activeDiagnostics = execution.diagnostics;
try {
tool!.lifecycle!.call!(input, executionContext, execution);
} finally {
this.activeDiagnostics = previous;
}
},
},
next,
context,
this.invocationPorts,
);
}
/** Output protocols plus one protocol per registered invocation. */
protocols(): ProtocolRegistry<ToolProtocol<Declaration>> {
return new ProtocolRegistry([...this.options.outputs(), ...this.invocationProtocols()]);
}
/**
* Protocols exposed to the model: output protocols plus invocations whose audience is `model` and
* which are currently available in `context`.
*/
modelProtocols(context: Types['context']): ProtocolRegistry<ToolProtocol<Declaration>> {
return new ProtocolRegistry([
...(this.options.modelOutputs?.(context) ?? this.options.outputs()),
...this.invocationProtocols(
(tool) => tool.audience === 'model' && toolActive(tool.active, context),
),
]);
}
/** Whether the protocol registered for this invocation is declared terminal. */
isTerminal(next: { type: string }): boolean {
return this.protocols().get(next.type)?.terminal === true;
}
/** `[invocationId, protocol]` pairs, optionally filtered by the owning tool. */
private invocationProtocols(
keep?: (tool: PreparedTool<Types, Declaration>) => boolean,
): (readonly [string, ToolProtocol<Declaration>])[] {
return [...this.invocations]
.filter(([, tool]) => !keep || keep(tool))
.map(
([id, tool]) =>
[id, tool.lifecycle!.protocol as ToolProtocol<Declaration>] as readonly [
string,
ToolProtocol<Declaration>,
],
);
}
/**
* Run `inspect` for every registered tool in registration order.
*
* `inspect` belongs to the all-tools group, so the selected tool participates exactly once
* through the same loop rather than through a second selected-only pass.
*/
assertDecision(decision: Types['decision'], context: Types['context']): void {
for (const tool of this.registered.values()) tool.lifecycle?.inspect?.(decision, context);
}
/** Run the selected invocation's authorization hook, if any. */
authorize(decision: Types['decision'], context: Types['context']): void {
this.selected(decision)?.lifecycle?.authorize?.(decision, context);
}
/** Whether the selected invocation should resume rather than start fresh; defaults to false. */
resume(decision: Types['decision'], context: Types['context']): boolean {
const lifecycle = this.selected(decision)?.lifecycle;
return lifecycle?.resume
? booleanResult(
lifecycle.resume(decision, context),
'Tool resume must return boolean synchronously',
)
: false;
}
/** Let the selected invocation rewrite the decision on completion, if it declares a hook. */
complete(decision: Types['decision'], context: Types['context']): Types['decision'] | undefined {
return this.selected(decision)?.lifecycle?.complete?.(decision, context);
}
/** Fold every tool's context projection over the value, in registration order. */
projectContext(value: unknown): unknown {
return [...this.registered.values()].reduce(
(current, tool) => tool.lifecycle?.projectContext?.(current) ?? current,
value,
);
}
/** Fold every tool's visible-text transform over the text. */
visibleText(text: string, context: Types['context']): string {
return [...this.registered.values()].reduce(
(value, tool) => tool.lifecycle?.visibleText?.(value, context) ?? value,
text,
);
}
/** Fold every tool's review transform over the draft. */
reviewDraft(draft: unknown, context: Types['context']): unknown {
return [...this.registered.values()].reduce(
(value, tool) => tool.lifecycle?.reviewDraft?.(value, context) ?? value,
draft,
);
}
/** Collected recovery context values; `undefined` results are omitted. */
recoveryContext(context: Types['context']): unknown[] {
return [...this.registered.values()].flatMap((tool) => {
const value = tool.lifecycle?.recoveryContext?.(context);
return value === undefined ? [] : [value];
});
}
/**
* The single output template that owns this delivery.
*
* @throws An error when more than one tool claims composition, because delivery would
* otherwise depend on registration order.
*/
composition(context: Types['context']): Types['composition'] | undefined {
const values = [...this.registered.values()].flatMap((tool) => {
const value = tool.lifecycle?.composition?.(context);
return value ? [value] : [];
});
breakWhile(values.length > 1, 'More than one output template owns this delivery');
return values[0];
}
/** Run every tool's finalize hook for the given phase. */
finalize(decision: Types['decision'], context: Types['context'], phase: Types['phase']): void {
for (const tool of this.registered.values())
tool.lifecycle?.finalize?.(decision, context, phase);
}
/** Whether any tool permits the interaction to be missing. */
missingInteractionAllowed(decision: Types['decision'], context: Types['context']): boolean {
return [...this.registered.values()].some((tool) =>
tool.lifecycle?.missingInteractionAllowed
? booleanResult(
tool.lifecycle.missingInteractionAllowed(decision, context),
'Missing interaction predicates must return boolean synchronously',
)
: false,
);
}
/** Task rules ordered by their declared `order`, joined into one prompt block. */
taskRules(context: Types['context']): string {
return [...this.registered.values()]
.flatMap((tool) => tool.lifecycle?.rules?.(context) ?? [])
.sort((left, right) => left.order - right.order)
.map((rule) => rule.text)
.filter(Boolean)
.join('\n\n');
}
/** Model-context fragments for one placement, ordered by each field's `order`. */
contextFields(context: Types['context'], placement: Types['placement']) {
return [...this.registered.values()]
.flatMap((tool) =>
(tool.lifecycle?.modelFields?.(context, placement) ?? [])
.filter((field) => field.placement === placement)
.map((field) => ({ tool, field })),
)
.sort((left, right) => left.field.order - right.field.order)
.map(({ tool, field }) => this.options.contextFragment(this.sources.get(tool.id)!, field));
}
/** Merged model-context values for one placement; later fragments win per key. */
modelFields(context: Types['context'], placement: Types['placement']) {
const fields: Record<string, unknown> = {};
for (const { value } of this.contextFields(context, placement)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
for (const [key, entry] of Object.entries(value))
// Own data properties keep keys such as `__proto__` from rewriting the merged result's
// prototype, matching how context assembly writes structured fields.
Object.defineProperty(fields, key, {
value: entry,
enumerable: true,
configurable: true,
writable: true,
});
}
return fields;
}
/** A tool may veto a goal by returning `false`; silence means support. */
supportsGoal(intentId: string, context: Types['context']): boolean {
return [...this.registered.values()].every(
(tool) => tool.lifecycle?.supportsGoal?.(intentId, context) !== false,
);
}
/**
* Describe registered tools, optionally limited by audience and availability.
*
* A tool with an `available` predicate is only listed when a context is supplied and the
* predicate holds, so a caller that omits the context sees just the unconditional tools.
*
* @param audience - Audience to keep; omit to include every audience.
* @param context - Context for availability checks.
* @returns Plain metadata; `goalIds: 'all'` is expanded to the current goal ids.
*/
describe(
audience?: string,
context?: Types['context'],
): {
id: Declaration['id'];
kind: Declaration['kind'];
description: Declaration['description'];
audience: Declaration['audience'];
effect: Declaration['effect'];
goalIds: string[];
}[] {
return [...this.registered.values()]
.filter(
(tool) =>
(!audience || tool.audience === audience) &&
(tool.active === undefined ||
(typeof tool.active === 'boolean'
? toolActive(tool.active, context)
: !!context && toolActive(tool.active, context))),
)
.map(({ id, kind, description, audience, effect, goalIds }) => ({
id,
kind,
description,
audience,
effect,
goalIds: goalIds === 'all' ? [...this.options.goalIds()] : [...goalIds],
}));
}
/**
* Reinstall every registered tool, plus the finalizer and normalizer, into another registry.
*
* The original definitions are reused rather than the compiled views, so the target applies
* its own policy checks and binding exactly as it would for a first registration.
*/
copyTo(target: ToolLifecycleRegistry<Types, Declaration>): void {
for (const tool of this.registered.values()) target.install(this.sources.get(tool.id)!, tool);
if (this.finalizer) target.registerFinalizer(this.finalizer);
if (this.normalizer) target.registerNormalizer(this.normalizer);
}
/** Freeze the registry: no further registration, normalizer or finalizer is accepted. */
seal(): this {
this.sealed = true;
this.tools.seal();
return this;
}
/** Tool that owns the invocation id named by the decision, if any. */
private selected(decision: Types['decision']): PreparedTool<Types, Declaration> | undefined {
return this.invocations.get(this.options.invocationId(decision));
}
}
ToolLifecycleRegistry as Tools