Skip to content

Commit fbd5641

Browse files
authored
Overlay trusted runtime tool declarations (#425)
* Overlay trusted runtime tool declarations * Harden runtime tool overlay boundary
1 parent fef392b commit fbd5641

11 files changed

Lines changed: 315 additions & 53 deletions

docs/runtime-and-tools.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,65 @@ Important options:
214214

215215
When mediated tool execution consults `completion_policy`, complete decisions stop the loop and record `completion_policy_stop` in `events[]`. Incomplete decisions with an empty message preserve the existing continue behavior. Incomplete decisions with a non-empty message append a normalized `user` text continuation message, record a `completion_policy_continue` lifecycle event in `events[]`, and emit the same event through `on_event`; the event metadata includes `tool_name`, `turn`, `message`, and caller-owned policy `context`. Agents API does not persist these diagnostics beyond the returned transcript/result surfaces.
216216

217+
### Trusted chat runtime tool overlays
218+
219+
The native `agents/chat` handler accepts runtime-local overlays only through the
220+
server-side `agents_api_runtime_tool_declarations` filter. Public REST,
221+
JSON-RPC, and chat request `client_context` fields cannot declare or select
222+
runtime tools; those transport fields are stripped before dispatch.
223+
224+
Each map key and declaration `name` must be the same canonical id already listed
225+
in the imported agent's `enabled_tools` allowlist. The declaration is normalized
226+
by `WP_Agent_Tool_Declaration::normalizeForConversationRequest()`. It can replace
227+
only the model-facing `description`, `parameters`, and runtime execution metadata
228+
for that allowlisted tool, including `runtime.executor_target`. It cannot change
229+
the imported declaration's ability binding, capability, mandatory flag, modes,
230+
categories, visibility, parameter bindings, defaults, or policy behavior.
231+
232+
The filter runs after agent selection and final session/run context resolution.
233+
Its exact signature is:
234+
235+
```php
236+
apply_filters(
237+
'agents_api_runtime_tool_declarations',
238+
array $declarations,
239+
?WP_Agent $agent,
240+
array $runtime_context // agent, agent_slug, session_id, run_id
241+
): array
242+
```
243+
244+
An explicit `runtime.executor_target` must be registered through
245+
`agents_api_tool_executors` in that exact `$runtime_context`. The selected
246+
registry is frozen for execution; an unavailable target fails closed and never
247+
falls back to the default ability executor. Duplicate canonical ids, aliases,
248+
malformed entries, undeclared tools, and unregistered targets reject the chat request with
249+
`agents_chat_invalid_runtime_tool_declaration`.
250+
251+
```php
252+
add_filter(
253+
'agents_api_runtime_tool_declarations',
254+
static function ( array $declarations, ?WP_Agent $agent, array $runtime_context ): array {
255+
if ( ! $agent instanceof WP_Agent || 'example-agent' !== $runtime_context['agent_slug'] ) {
256+
return $declarations;
257+
}
258+
259+
$declarations['example/write_file'] = array(
260+
'name' => 'example/write_file',
261+
'source' => 'example',
262+
'description' => 'Write a file in the isolated runtime.',
263+
'parameters' => array( 'type' => 'object', 'properties' => array( 'path' => array( 'type' => 'string' ) ) ),
264+
'executor' => 'host',
265+
'scope' => 'run',
266+
'runtime' => array( 'executor_target' => 'example/isolated-runtime' ),
267+
);
268+
269+
return $declarations;
270+
},
271+
10,
272+
3
273+
);
274+
```
275+
217276
### Minimal caller-managed loop
218277

219278
```php

src/Channels/register-agents-chat-ability.php

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -435,35 +435,33 @@ function agents_chat_input_schema(): array {
435435
'type' => 'boolean',
436436
'description' => 'Whether this turn is an explicit agent-to-agent delegation call.',
437437
),
438-
'runtime_tools' => array(
439-
'type' => 'object',
440-
'description' => 'Explicit runtime-local tool declarations supplied by a trusted caller for this turn.',
441-
'additionalProperties' => array( 'type' => 'object' ),
442-
),
443-
'runtime_tool_declarations' => array(
444-
'type' => 'object',
445-
'description' => 'Alias for explicit runtime-local tool declarations supplied by a trusted caller for this turn.',
446-
'additionalProperties' => array( 'type' => 'object' ),
447-
),
448-
'tool_declarations' => array(
449-
'type' => 'object',
450-
'description' => 'Transport-level tool declarations supplied by a trusted caller for this turn.',
451-
'additionalProperties' => array( 'type' => 'object' ),
452-
),
453-
'runtime_tool_callback' => array(
454-
'type' => 'string',
455-
'description' => 'Runtime-local callback identifier for executing runtime tool calls in trusted in-process callers.',
456-
),
457-
'runtime_tool_timeout' => array(
458-
'type' => 'integer',
459-
'description' => 'Runtime-local tool timeout in seconds.',
460-
),
461438
),
462439
),
463440
),
464441
);
465442
}
466443

444+
/**
445+
* Remove transport-controlled runtime declaration fields from client context.
446+
*
447+
* Runtime declaration overlays are server-only and arrive through
448+
* `agents_api_runtime_tool_declarations`, never a public request envelope.
449+
*
450+
* @param array<string,mixed> $client_context Client context from a transport.
451+
* @return array<string,mixed> Client context without runtime declaration fields.
452+
*/
453+
function agents_chat_strip_runtime_tool_declaration_fields( array $client_context ): array {
454+
unset(
455+
$client_context['runtime_tools'],
456+
$client_context['runtime_tool_declarations'],
457+
$client_context['tool_declarations'],
458+
$client_context['runtime_tool_callback'],
459+
$client_context['runtime_tool_timeout']
460+
);
461+
462+
return $client_context;
463+
}
464+
467465
/**
468466
* Canonical execution principal schema for agents/chat.
469467
*

src/Channels/register-agents-chat-jsonrpc-route.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent, ar
319319
$session_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['sessionId'] ?? null );
320320
$run_id = \AgentsAPI\AI\agents_api_scalar_to_string( $params['id'] ?? null );
321321

322-
$client_context = agents_chat_jsonrpc_client_context( $message );
322+
$client_context = agents_chat_strip_runtime_tool_declaration_fields( agents_chat_jsonrpc_client_context( $message ) );
323323
$client_context = array_merge(
324324
$client_context,
325325
array(
@@ -354,7 +354,16 @@ function agents_chat_jsonrpc_input_from_params( array $params, string $agent, ar
354354
/** @var mixed $filtered Hosts may return invalid values from this filter. */
355355
$filtered = apply_filters( 'agents_chat_jsonrpc_input', $input, $params, $agent );
356356

357-
return is_array( $filtered ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $filtered ) : $input;
357+
if ( ! is_array( $filtered ) ) {
358+
return $input;
359+
}
360+
361+
$input = \AgentsAPI\AI\agents_api_string_keyed_array( $filtered );
362+
if ( is_array( $input['client_context'] ?? null ) ) {
363+
$input['client_context'] = agents_chat_strip_runtime_tool_declaration_fields( \AgentsAPI\AI\agents_api_string_keyed_array( $input['client_context'] ) );
364+
}
365+
366+
return $input;
358367
}
359368

360369
/**

src/Channels/register-default-agents-chat-handler.php

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use AgentsAPI\AI\WP_Agent_Message;
4242
use AgentsAPI\AI\Tools\WP_Agent_Ability_Tool_Executor;
4343
use AgentsAPI\AI\Tools\WP_Agent_Tool_Declaration;
44+
use AgentsAPI\AI\Tools\WP_Agent_Tool_Executor_Registry;
4445
use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Sessions;
4546
use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store;
4647
use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope;
@@ -125,10 +126,9 @@ public static function execute( array $input ) {
125126
);
126127
}
127128

128-
$system_prompt = self::resolve_system_prompt( $config );
129-
$tool_declarations = self::resolve_tool_declarations( $config );
130-
$max_turns = self::resolve_max_turns( $input, $config );
131-
$tool_call_rules = self::resolve_tool_call_rules( $config );
129+
$system_prompt = self::resolve_system_prompt( $config );
130+
$max_turns = self::resolve_max_turns( $input, $config );
131+
$tool_call_rules = self::resolve_tool_call_rules( $config );
132132

133133
$store = WP_Agent_Conversation_Sessions::get_store( $input );
134134
$session_id = is_string( $input['session_id'] ?? null ) ? trim( $input['session_id'] ) : '';
@@ -150,15 +150,39 @@ public static function execute( array $input ) {
150150
$session_id = self::generate_session_id();
151151
}
152152

153+
$runtime_context = array(
154+
'agent' => $agent,
155+
'agent_slug' => $agent_slug,
156+
'session_id' => $session_id,
157+
'run_id' => is_string( $input['run_id'] ?? null ) ? trim( $input['run_id'] ) : '',
158+
);
159+
$executor_registry = WP_Agent_Tool_Executor_Registry::fromFilters( $runtime_context );
160+
$tool_declarations = self::resolve_tool_declarations( $config, self::runtime_tool_declarations( $agent, $runtime_context ), $executor_registry );
161+
if ( is_wp_error( $tool_declarations ) ) {
162+
return $tool_declarations;
163+
}
164+
$tool_declarations = ( new \WP_Agent_Tool_Policy() )->resolve(
165+
$tool_declarations,
166+
array(
167+
'agent_config' => $config,
168+
'allow_only' => is_array( $input['allow_only'] ?? null ) ? $input['allow_only'] : array(),
169+
'tool_policy' => is_array( $input['tool_policy'] ?? null ) ? $input['tool_policy'] : array(),
170+
'principal' => $input['principal'] ?? null,
171+
)
172+
);
173+
153174
$messages[] = WP_Agent_Message::text( 'user', $message );
154175
$messages = WP_Agent_Message::normalize_many( $messages );
155176

156177
$loop_options = array(
157178
'system_prompt' => $system_prompt,
158179
'max_turns' => $max_turns,
159180
'context' => array(
160-
'session_id' => $session_id,
161-
'agent_slug' => $agent_slug,
181+
'session_id' => $session_id,
182+
'agent_slug' => $agent_slug,
183+
'run_id' => $runtime_context['run_id'],
184+
'principal' => $input['principal'] ?? null,
185+
'tool_executor_registry' => $executor_registry,
162186
),
163187
);
164188
if ( ! empty( $tool_declarations ) ) {
@@ -268,14 +292,24 @@ private static function resolve_system_prompt( array $config ): string {
268292
* model-facing name is the ability name; {@see WP_Agent_Ability_Tool_Executor}
269293
* dispatches it back through the Abilities API.
270294
*
295+
* Runtime overlays come only from the server-side
296+
* `agents_api_runtime_tool_declarations` filter after the agent, generated
297+
* session id, and run id are resolved. Each entry must name an existing
298+
* canonical enabled tool. Overlays can replace only model-facing description,
299+
* parameters, and runtime execution metadata.
300+
*
271301
* @param array<string,mixed> $config Agent default config.
272-
* @return array<string,array<string,mixed>> Declarations keyed by tool name.
302+
* @param array<mixed> $overlays Server-provided declaration overlays.
303+
* @param WP_Agent_Tool_Executor_Registry $executor_registry Frozen registry for this run.
304+
* @return array<string,array<mixed>>|\WP_Error Declarations, or an invalid overlay error.
273305
*/
274-
private static function resolve_tool_declarations( array $config ): array {
275-
$names = array();
276-
foreach ( array( 'enabled_tools', 'tools', 'abilities', 'tool_names' ) as $key ) {
277-
if ( is_array( $config[ $key ] ?? null ) ) {
278-
$names = array_merge( $names, $config[ $key ] );
306+
private static function resolve_tool_declarations( array $config, array $overlays = array(), ?WP_Agent_Tool_Executor_Registry $executor_registry = null ) {
307+
$names = is_array( $config['enabled_tools'] ?? null ) ? $config['enabled_tools'] : array();
308+
if ( empty( $names ) ) {
309+
foreach ( array( 'tools', 'abilities', 'tool_names' ) as $key ) {
310+
if ( is_array( $config[ $key ] ?? null ) ) {
311+
$names = array_merge( $names, $config[ $key ] );
312+
}
279313
}
280314
}
281315

@@ -313,9 +347,79 @@ private static function resolve_tool_declarations( array $config ): array {
313347
);
314348
}
315349

350+
if ( array() === $overlays ) {
351+
return $declarations;
352+
}
353+
$executor_registry = $executor_registry ?? new WP_Agent_Tool_Executor_Registry();
354+
$seen_names = array();
355+
$seen_aliases = array();
356+
foreach ( $overlays as $map_name => $overlay ) {
357+
if ( ! is_string( $map_name ) || ! is_array( $overlay ) ) {
358+
return self::runtime_tool_declaration_error( 'declaration' );
359+
}
360+
361+
try {
362+
$normalized = WP_Agent_Tool_Declaration::normalizeForConversationRequest( $overlay );
363+
} catch ( \InvalidArgumentException $error ) {
364+
return self::runtime_tool_declaration_error( $error->getMessage() );
365+
}
366+
367+
$name = $normalized['name'] ?? '';
368+
if ( ! is_string( $name ) || $map_name !== $name || ! isset( $declarations[ $name ] ) || isset( $seen_names[ $name ] ) ) {
369+
return self::runtime_tool_declaration_error( 'name' );
370+
}
371+
$seen_names[ $name ] = true;
372+
373+
$alias = $normalized['provider_safe_name'] ?? WP_Agent_Tool_Declaration::providerSafeName( $name );
374+
if ( ! is_string( $alias ) || isset( $seen_aliases[ $alias ] ) ) {
375+
return self::runtime_tool_declaration_error( 'provider_safe_name' );
376+
}
377+
$seen_aliases[ $alias ] = true;
378+
379+
$target_id = WP_Agent_Tool_Executor_Registry::targetIdFromDeclaration( $normalized );
380+
$runtime = is_array( $normalized['runtime'] ?? null ) ? $normalized['runtime'] : array();
381+
$declares_target = array_key_exists( WP_Agent_Tool_Executor_Registry::RUNTIME_EXECUTOR_TARGET, $runtime );
382+
if ( $declares_target && ( '' === $target_id || null === $executor_registry->executorForTarget( $target_id ) ) ) {
383+
return self::runtime_tool_declaration_error( 'executor_target' );
384+
}
385+
386+
// Imported declaration policy, bindings, and ability identity remain immutable.
387+
foreach ( array( 'description', 'parameters', 'runtime' ) as $field ) {
388+
if ( array_key_exists( $field, $normalized ) ) {
389+
$declarations[ $name ][ $field ] = $normalized[ $field ];
390+
}
391+
}
392+
}
393+
316394
return $declarations;
317395
}
318396

397+
/**
398+
* Collect server-only runtime tool declaration overlays for this run.
399+
*
400+
* @param \WP_Agent|null $agent Selected agent, or null for agent-less turns.
401+
* @param array<string,mixed> $runtime_context Final server-generated run context.
402+
* @return array<mixed> Declaration overlay map supplied by server code.
403+
*/
404+
private static function runtime_tool_declarations( ?\WP_Agent $agent, array $runtime_context ): array {
405+
$overlays = apply_filters( 'agents_api_runtime_tool_declarations', array(), $agent, $runtime_context );
406+
return is_array( $overlays ) ? $overlays : array( '__invalid__' => $overlays );
407+
}
408+
409+
/**
410+
* Build the public error returned for a rejected trusted-runtime overlay.
411+
*
412+
* @param string $reason Machine-readable validation reason.
413+
* @return \WP_Error
414+
*/
415+
private static function runtime_tool_declaration_error( string $reason ): \WP_Error {
416+
return new \WP_Error(
417+
'agents_chat_invalid_runtime_tool_declaration',
418+
'Runtime tool declarations must be a canonical, allowlisted map supplied by the server runtime.',
419+
array( 'status' => 400, 'reason' => $reason )
420+
);
421+
}
422+
319423
/**
320424
* Resolve declarative deterministic tool-call rules from the agent config.
321425
*

src/Channels/register-frontend-chat-rest-route.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ function agents_frontend_chat_rest_input( \WP_REST_Request $request ) {
121121

122122
$client_context = $request->get_param( 'client_context' );
123123
$client_context = is_array( $client_context ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $client_context ) : array();
124+
$client_context = agents_chat_strip_runtime_tool_declaration_fields( $client_context );
124125
$session_id = $request->get_param( 'session_id' );
125126
$client_name = \AgentsAPI\AI\agents_api_scalar_to_string( $client_context['client_name'] ?? null );
126127
$client_context = array_merge(
@@ -158,6 +159,9 @@ function agents_frontend_chat_rest_input( \WP_REST_Request $request ) {
158159
return $cache[ $request ];
159160
}
160161
$input = \AgentsAPI\AI\agents_api_string_keyed_array( $filtered_input );
162+
if ( is_array( $input['client_context'] ?? null ) ) {
163+
$input['client_context'] = agents_chat_strip_runtime_tool_declaration_fields( \AgentsAPI\AI\agents_api_string_keyed_array( $input['client_context'] ) );
164+
}
161165

162166
if ( '' === \AgentsAPI\AI\agents_api_scalar_to_string( $input['agent'] ?? null ) || '' === trim( \AgentsAPI\AI\agents_api_scalar_to_string( $input['message'] ?? null ) ) ) {
163167
$cache[ $request ] = new \WP_Error(
@@ -182,6 +186,7 @@ function agents_frontend_chat_rest_input( \WP_REST_Request $request ) {
182186
function agents_frontend_chat_rest_scope( \WP_REST_Request $request ): array {
183187
$client_context = $request->get_param( 'client_context' );
184188
$client_context = is_array( $client_context ) ? \AgentsAPI\AI\agents_api_string_keyed_array( $client_context ) : array();
189+
$client_context = agents_chat_strip_runtime_tool_declaration_fields( $client_context );
185190
$scope = \AgentsAPI\AI\Auth\agents_access_request_scope(
186191
array(
187192
'workspace_id' => $request->get_param( 'workspace_id' ),

src/Tools/class-wp-agent-tool-execution-core.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ public function prepareWP_Agent_Tool_Call( string $tool_name, array $tool_parame
105105
public function executePreparedTool( array $tool_call, array $tool_definition, WP_Agent_Tool_Executor $executor, array $context = array() ): array {
106106
$tool_call = WP_Agent_Tool_Call::normalize( $tool_call );
107107
$executor = $this->resolveExecutorForTool( $tool_definition, $executor, $context );
108+
if ( null === $executor ) {
109+
$tool_name = is_string( $tool_call['tool_name'] ?? null ) ? $tool_call['tool_name'] : '';
110+
return WP_Agent_Tool_Result::error( $tool_name, 'Tool execution target is unavailable.', array( 'error_type' => 'executor_target_unavailable' ) );
111+
}
108112
try {
109113
$result = $executor->executeWP_Agent_Tool_Call( $tool_call, $tool_definition, $context );
110114
} catch ( \Throwable $throwable ) {
@@ -177,19 +181,22 @@ public function executeTool( string $tool_name, array $tool_parameters, array $a
177181
* @param array<mixed> $tool_definition Tool declaration selected for the call.
178182
* @param WP_Agent_Tool_Executor $default_executor Caller-provided default executor.
179183
* @param array<mixed> $context Host runtime context for this invocation.
180-
* @return WP_Agent_Tool_Executor Executor to dispatch the tool call to.
184+
* @return WP_Agent_Tool_Executor|null Executor to dispatch the tool call, or null when a declared target is unavailable.
181185
*/
182-
private function resolveExecutorForTool( array $tool_definition, WP_Agent_Tool_Executor $default_executor, array $context ): WP_Agent_Tool_Executor {
186+
private function resolveExecutorForTool( array $tool_definition, WP_Agent_Tool_Executor $default_executor, array $context ): ?WP_Agent_Tool_Executor {
183187
// Skip registry resolution entirely when the tool names no target, so the
184188
// common ability-backed path never builds or consults a registry.
185189
if ( '' === WP_Agent_Tool_Executor_Registry::targetIdFromDeclaration( $tool_definition ) ) {
186190
return $default_executor;
187191
}
188192

189-
if ( null === $this->executor_registry ) {
193+
$frozen_registry = $context['tool_executor_registry'] ?? null;
194+
if ( $frozen_registry instanceof WP_Agent_Tool_Executor_Registry ) {
195+
$this->executor_registry = $frozen_registry;
196+
} elseif ( null === $this->executor_registry ) {
190197
$this->executor_registry = WP_Agent_Tool_Executor_Registry::fromFilters( $context );
191198
}
192199

193-
return $this->executor_registry->resolveForTool( $tool_definition, $default_executor );
200+
return $this->executor_registry->executorForTarget( WP_Agent_Tool_Executor_Registry::targetIdFromDeclaration( $tool_definition ) );
194201
}
195202
}

0 commit comments

Comments
 (0)