From 99bd261e5eb0a51f7edd3cb7b0aa20c16a2d9989 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 18 Jun 2026 14:08:51 -0400 Subject: [PATCH] Add generic runtime package run contract --- README.md | 5 + agents-api.php | 3 + composer.json | 1 + ...s-wp-agent-runtime-package-run-request.php | 137 ++++++++++++++ ...ss-wp-agent-runtime-package-run-result.php | 154 ++++++++++++++++ .../register-runtime-package-run-ability.php | 173 ++++++++++++++++++ tests/runtime-package-run-contract-smoke.php | 120 ++++++++++++ 7 files changed, 593 insertions(+) create mode 100644 src/Runtime/class-wp-agent-runtime-package-run-request.php create mode 100644 src/Runtime/class-wp-agent-runtime-package-run-result.php create mode 100644 src/Runtime/register-runtime-package-run-ability.php create mode 100644 tests/runtime-package-run-contract-smoke.php diff --git a/README.md b/README.md index 2f80f07..a79719f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Agents API sits between tool/action discovery and product-specific automation. I - Session and persistence contracts where they are provider-neutral. - Retrieved context authority vocabulary, context item shape, and conflict resolution contracts. - Workflow spec value object, structural validator, in-memory registry, abstract runner with `ability` and `agent` step types, `Store` and `Run_Recorder` interfaces, optional Action Scheduler bridge, and three canonical abilities (`agents/run-workflow`, `agents/validate-workflow`, `agents/describe-workflow`). +- Runtime package workflow execution request/result contracts and the canonical dispatcher ability (`agents/run-runtime-package`) for consumer-owned package materialization and execution adapters. ## What Agents API Does Not Own @@ -58,6 +59,7 @@ Agents API sits between tool/action discovery and product-specific automation. I - Product CLI commands beyond generic substrate needs. - Public REST controllers in v1 unless they are separately designed. - Product runner adapters that assemble prompts, choose concrete tools, materialize storage, or decide product policy. +- Concrete runtime package materialization, package source checkout, sandbox provisioning, provider mapping, run polling, or evidence artifact upload. The package run contract only defines the request/result envelope and dispatcher seam. - Concrete tool execution adapters, prompt assembly policy, or product storage/materialization policy. - Product-specific consent UX, support routing, escalation targets, or transcript-sharing policy. - Concrete memory retrieval, file projection, convention-path writing, or filesystem layout adapters. @@ -188,6 +190,8 @@ wp_register_agent( - `AgentsAPI\AI\WP_Agent_Byte_Limit_Tool_Result_Truncator` - `AgentsAPI\AI\WP_Agent_Conversation_Result` - `AgentsAPI\AI\WP_Agent_Chat_Run_Control` +- `AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Request` +- `AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Result` - `AgentsAPI\AI\WP_Agent_Conversation_Loop` - `WP_Agent_Consent_Policy` - `WP_Agent_Default_Consent_Policy` @@ -214,6 +218,7 @@ wp_register_agent( - `AgentsAPI\AI\Tools\WP_Agent_Tool_Execution_Core` - `AgentsAPI\AI\Tools\WP_Agent_Tool_Result` - `agents/ability-search` / `agents/ability-call` +- `agents/run-runtime-package` - `agents/chat` / `agents/get-chat-run` / `agents/cancel-chat-run` / `agents/queue-chat-message` - `AgentsAPI\AI\Approvals\WP_Agent_Approval_Decision` - `AgentsAPI\AI\Approvals\WP_Agent_Pending_Action` diff --git a/agents-api.php b/agents-api.php index 1aa3537..4eb7914 100644 --- a/agents-api.php +++ b/agents-api.php @@ -167,6 +167,8 @@ require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-option-run-control-store.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-run-outcome.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-package-run-request.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-runtime-package-run-result.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-result.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-chat-run-control.php'; require_once AGENTS_API_PATH . 'src/Tasks/class-wp-agent-task-run-control.php'; @@ -214,6 +216,7 @@ require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-channel.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-ability.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-run-control-abilities.php'; +require_once AGENTS_API_PATH . 'src/Runtime/register-runtime-package-run-ability.php'; require_once AGENTS_API_PATH . 'src/Tasks/register-agents-task-abilities.php'; require_once AGENTS_API_PATH . 'src/Channels/register-frontend-chat-rest-route.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-jsonrpc-route.php'; diff --git a/composer.json b/composer.json index 18247ad..d9d5277 100644 --- a/composer.json +++ b/composer.json @@ -86,6 +86,7 @@ "php tests/run-outcome-status-smoke.php", "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", + "php tests/runtime-package-run-contract-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", "php tests/task-execution-smoke.php", diff --git a/src/Runtime/class-wp-agent-runtime-package-run-request.php b/src/Runtime/class-wp-agent-runtime-package-run-request.php new file mode 100644 index 0000000..b7a7471 --- /dev/null +++ b/src/Runtime/class-wp-agent-runtime-package-run-request.php @@ -0,0 +1,137 @@ + $package Portable package descriptor. + * @param array $workflow Workflow selector/spec. + * @param array $input Runtime input supplied to the workflow. + * @param array $options Execution options such as budgets. + * @param array $metadata Caller metadata for observability. + * @param array $replay Replay/materialization hints. + */ + public function __construct( + private array $package, + private array $workflow, + private array $input = array(), + private array $options = array(), + private array $metadata = array(), + private array $replay = array() + ) {} + + /** + * Build from the canonical ability/filter input. + * + * @param array $input Raw input. + * @return self|\WP_Error + */ + public static function from_array( array $input ) { + $package = self::array_value( $input['package'] ?? array() ); + $workflow = self::array_value( $input['workflow'] ?? array() ); + + $package_source = self::string_value( $package['source'] ?? '' ); + $package_slug = self::string_value( $package['slug'] ?? $package['id'] ?? '' ); + if ( '' === $package_source && '' === $package_slug ) { + return new \WP_Error( + 'agents_runtime_package_run_missing_package', + 'Runtime package run requests require package.source or package.slug.' + ); + } + + $workflow_id = self::string_value( $workflow['id'] ?? '' ); + $workflow_spec = self::array_value( $workflow['spec'] ?? array() ); + if ( '' === $workflow_id && empty( $workflow_spec ) ) { + return new \WP_Error( + 'agents_runtime_package_run_missing_workflow', + 'Runtime package run requests require workflow.id or workflow.spec.' + ); + } + + return new self( + $package, + $workflow, + self::array_value( $input['input'] ?? array() ), + self::array_value( $input['options'] ?? array() ), + self::array_value( $input['metadata'] ?? array() ), + self::array_value( $input['replay'] ?? array() ) + ); + } + + /** @return array */ + public function get_package(): array { + return $this->package; + } + + /** @return array */ + public function get_workflow(): array { + return $this->workflow; + } + + /** @return array */ + public function get_input(): array { + return $this->input; + } + + /** @return array */ + public function get_options(): array { + return $this->options; + } + + /** @return array */ + public function get_metadata(): array { + return $this->metadata; + } + + /** @return array */ + public function get_replay(): array { + return $this->replay; + } + + /** @return array */ + public function to_array(): array { + return array( + 'package' => $this->package, + 'workflow' => $this->workflow, + 'input' => $this->input, + 'options' => $this->options, + 'metadata' => $this->metadata, + 'replay' => $this->replay, + ); + } + + private static function string_value( mixed $value ): string { + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } + + /** @return array */ + private static function array_value( mixed $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $normalized = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $normalized[ $key ] = $item; + } + } + return $normalized; + } +} diff --git a/src/Runtime/class-wp-agent-runtime-package-run-result.php b/src/Runtime/class-wp-agent-runtime-package-run-result.php new file mode 100644 index 0000000..c3acd52 --- /dev/null +++ b/src/Runtime/class-wp-agent-runtime-package-run-result.php @@ -0,0 +1,154 @@ + $result Consumer-defined output. + * @param array $error Stable error envelope. + * @param array> $evidence_refs Neutral artifact/log refs. + * @param array $metadata Host/runtime metadata. + * @param array $replay Replay/materialization metadata. + */ + public function __construct( + private string $status, + private string $run_id = '', + private array $result = array(), + private array $error = array(), + private array $evidence_refs = array(), + private array $metadata = array(), + private array $replay = array() + ) {} + + /** + * @param array $value Raw handler result. + */ + public static function from_array( array $value ): self { + $status = self::string_value( $value['status'] ?? '' ); + if ( ! in_array( $status, self::statuses(), true ) ) { + $status = self::STATUS_SUCCEEDED; + } + + return new self( + $status, + self::string_value( $value['run_id'] ?? '' ), + self::array_value( $value['result'] ?? array() ), + self::array_value( $value['error'] ?? array() ), + self::list_of_arrays( $value['evidence_refs'] ?? array() ), + self::array_value( $value['metadata'] ?? array() ), + self::array_value( $value['replay'] ?? array() ) + ); + } + + /** @return array */ + public static function statuses(): array { + return array( + self::STATUS_PENDING, + self::STATUS_RUNNING, + self::STATUS_SUCCEEDED, + self::STATUS_FAILED, + self::STATUS_CANCELLED, + self::STATUS_SKIPPED, + ); + } + + public function get_status(): string { + return $this->status; + } + + public function get_run_id(): string { + return $this->run_id; + } + + /** @return array */ + public function get_result(): array { + return $this->result; + } + + /** @return array */ + public function get_error(): array { + return $this->error; + } + + /** @return array> */ + public function get_evidence_refs(): array { + return $this->evidence_refs; + } + + /** @return array */ + public function get_metadata(): array { + return $this->metadata; + } + + /** @return array */ + public function get_replay(): array { + return $this->replay; + } + + /** @return array */ + public function to_array(): array { + return array( + 'status' => $this->status, + 'run_id' => $this->run_id, + 'result' => $this->result, + 'error' => $this->error, + 'evidence_refs' => $this->evidence_refs, + 'metadata' => $this->metadata, + 'replay' => $this->replay, + ); + } + + private static function string_value( mixed $value ): string { + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } + + /** @return array */ + private static function array_value( mixed $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $normalized = array(); + foreach ( $value as $key => $item ) { + if ( is_string( $key ) ) { + $normalized[ $key ] = $item; + } + } + return $normalized; + } + + /** @return array> */ + private static function list_of_arrays( mixed $value ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $items = array(); + foreach ( $value as $item ) { + if ( is_array( $item ) ) { + $items[] = self::array_value( $item ); + } + } + return $items; + } +} diff --git a/src/Runtime/register-runtime-package-run-ability.php b/src/Runtime/register-runtime-package-run-ability.php new file mode 100644 index 0000000..9a4c7c7 --- /dev/null +++ b/src/Runtime/register-runtime-package-run-ability.php @@ -0,0 +1,173 @@ + 'Agents API', + 'description' => 'Cross-cutting abilities provided by the Agents API substrate.', + ) + ); + } +); + +add_action( + 'wp_abilities_api_init', + static function (): void { + if ( wp_has_ability( AGENTS_RUN_RUNTIME_PACKAGE_ABILITY ) ) { + return; + } + + wp_register_ability( + AGENTS_RUN_RUNTIME_PACKAGE_ABILITY, + array( + 'label' => 'Run Runtime Package', + 'description' => 'Canonical entry point for running a portable agent package workflow. Dispatches to a consumer-provided runtime handler.', + 'category' => 'agents-api', + 'input_schema' => agents_runtime_package_run_input_schema(), + 'output_schema' => agents_runtime_package_run_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\agents_runtime_package_run_dispatch', + 'permission_callback' => __NAMESPACE__ . '\agents_runtime_package_run_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), + ), + ) + ); + } +); + +/** + * Dispatch a runtime package workflow run to a registered consumer handler. + * + * @param array $input Canonical input. + * @return array|\WP_Error + */ +function agents_runtime_package_run_dispatch( array $input ) { + $request = WP_Agent_Runtime_Package_Run_Request::from_array( $input ); + if ( is_wp_error( $request ) ) { + do_action( 'agents_runtime_package_run_dispatch_failed', $request->get_error_code(), $input ); + return $request; + } + + /** + * Filters the runtime package execution handler. + * + * Handlers receive the value object and raw input and must return a canonical + * result array, WP_Agent_Runtime_Package_Run_Result, or WP_Error. + * + * @param callable|null $handler Current handler, or null. + * @param WP_Agent_Runtime_Package_Run_Request $request Normalized request. + * @param array $input Raw ability input. + */ + $handler = apply_filters( 'wp_agent_runtime_package_run_handler', null, $request, $input ); + if ( ! is_callable( $handler ) ) { + do_action( 'agents_runtime_package_run_dispatch_failed', 'no_handler', $input ); + return new \WP_Error( + 'agents_runtime_package_run_no_handler', + 'No agents/run-runtime-package handler is registered. Install a consumer runtime or add a callable to the wp_agent_runtime_package_run_handler filter.' + ); + } + + $result = call_user_func( $handler, $request, $input ); + if ( is_wp_error( $result ) ) { + do_action( 'agents_runtime_package_run_dispatch_failed', $result->get_error_code(), $input ); + return $result; + } + + if ( $result instanceof WP_Agent_Runtime_Package_Run_Result ) { + return $result->to_array(); + } + + if ( ! is_array( $result ) ) { + do_action( 'agents_runtime_package_run_dispatch_failed', 'invalid_result', $input ); + return new \WP_Error( + 'agents_runtime_package_run_invalid_result', + 'agents/run-runtime-package handlers must return an array, WP_Agent_Runtime_Package_Run_Result, or WP_Error.' + ); + } + + return WP_Agent_Runtime_Package_Run_Result::from_array( $result )->to_array(); +} + +/** + * Permission gate for runtime package execution. + * + * @param array $input Canonical input. + */ +function agents_runtime_package_run_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false; + + /** + * Filters permission for agents/run-runtime-package. + * + * @param bool $allowed Default permission result. + * @param array $input Canonical input. + */ + return (bool) apply_filters( 'agents_runtime_package_run_permission', $allowed, $input ); +} + +/** @return array */ +function agents_runtime_package_run_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'package', 'workflow' ), + 'properties' => array( + 'package' => array( + 'type' => 'object', + 'description' => 'Portable package descriptor. Use source for a path/URI or slug/id for a runtime-resolved package.', + ), + 'workflow' => array( + 'type' => 'object', + 'description' => 'Workflow selector or inline spec. Provide id or spec.', + ), + 'input' => array( 'type' => 'object' ), + 'options' => array( 'type' => 'object' ), + 'metadata' => array( 'type' => 'object' ), + 'replay' => array( 'type' => 'object' ), + ), + ); +} + +/** @return array */ +function agents_runtime_package_run_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'status', 'result', 'evidence_refs' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'enum' => WP_Agent_Runtime_Package_Run_Result::statuses(), + ), + 'run_id' => array( 'type' => 'string' ), + 'result' => array( 'type' => 'object' ), + 'error' => array( 'type' => 'object' ), + 'evidence_refs' => array( + 'type' => 'array', + 'items' => array( 'type' => 'object' ), + ), + 'metadata' => array( 'type' => 'object' ), + 'replay' => array( 'type' => 'object' ), + ), + ); +} diff --git a/tests/runtime-package-run-contract-smoke.php b/tests/runtime-package-run-contract-smoke.php new file mode 100644 index 0000000..54fb915 --- /dev/null +++ b/tests/runtime-package-run-contract-smoke.php @@ -0,0 +1,120 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $value ): bool { + return $value instanceof WP_Error; + } +} + +require_once __DIR__ . '/agents-api-smoke-helpers.php'; +require_once __DIR__ . '/../src/Runtime/class-wp-agent-runtime-package-run-request.php'; +require_once __DIR__ . '/../src/Runtime/class-wp-agent-runtime-package-run-result.php'; +require_once __DIR__ . '/../src/Runtime/register-runtime-package-run-ability.php'; + +use AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Request; +use AgentsAPI\AI\WP_Agent_Runtime_Package_Run_Result; + +echo "\n[1] Request validates package and workflow selectors:\n"; +$request = WP_Agent_Runtime_Package_Run_Request::from_array( + array( + 'package' => array( + 'source' => 'bundles/site-builder', + 'slug' => 'site-builder', + ), + 'workflow' => array( 'id' => 'build-site' ), + 'input' => array( 'prompt' => 'Build a site.' ), + 'options' => array( 'max_turns' => 8 ), + ) +); +agents_api_smoke_assert_equals( true, $request instanceof WP_Agent_Runtime_Package_Run_Request, 'valid request normalizes to value object', $failures, $passes ); +agents_api_smoke_assert_equals( 'site-builder', $request instanceof WP_Agent_Runtime_Package_Run_Request ? $request->get_package()['slug'] ?? '' : '', 'package slug is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'Build a site.', $request instanceof WP_Agent_Runtime_Package_Run_Request ? $request->get_input()['prompt'] ?? '' : '', 'runtime input is preserved', $failures, $passes ); + +$missing_package = WP_Agent_Runtime_Package_Run_Request::from_array( array( 'workflow' => array( 'id' => 'build-site' ) ) ); +agents_api_smoke_assert_equals( true, is_wp_error( $missing_package ), 'package is required', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_runtime_package_run_missing_package', is_wp_error( $missing_package ) ? $missing_package->get_error_code() : '', 'missing package error is stable', $failures, $passes ); + +$missing_workflow = WP_Agent_Runtime_Package_Run_Request::from_array( array( 'package' => array( 'slug' => 'site-builder' ) ) ); +agents_api_smoke_assert_equals( true, is_wp_error( $missing_workflow ), 'workflow is required', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_runtime_package_run_missing_workflow', is_wp_error( $missing_workflow ) ? $missing_workflow->get_error_code() : '', 'missing workflow error is stable', $failures, $passes ); + +echo "\n[2] Result envelope normalizes status, result, and evidence refs:\n"; +$result = WP_Agent_Runtime_Package_Run_Result::from_array( + array( + 'status' => 'succeeded', + 'run_id' => 'run-123', + 'result' => array( 'summary' => 'created' ), + 'evidence_refs' => array( + array( + 'type' => 'artifact', + 'label' => 'transcript', + 'url' => 'https://example.com/artifacts/run-123/transcript.json', + ), + ), + 'metadata' => array( 'runtime' => 'consumer-owned' ), + ) +); +agents_api_smoke_assert_equals( 'succeeded', $result->get_status(), 'status is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'run-123', $result->get_run_id(), 'run id is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'created', $result->get_result()['summary'] ?? '', 'result output is preserved', $failures, $passes ); +agents_api_smoke_assert_equals( 'transcript', $result->get_evidence_refs()[0]['label'] ?? '', 'evidence refs are preserved', $failures, $passes ); + +$default_status = WP_Agent_Runtime_Package_Run_Result::from_array( array( 'status' => 'unknown' ) ); +agents_api_smoke_assert_equals( 'succeeded', $default_status->get_status(), 'unknown status defaults to succeeded for legacy arrays', $failures, $passes ); + +echo "\n[3] Dispatcher requires a handler and normalizes handler output:\n"; +$GLOBALS['__runtime_package_handler_called'] = null; +add_filter( + 'wp_agent_runtime_package_run_handler', + static function ( $handler, WP_Agent_Runtime_Package_Run_Request $handler_request, array $raw_input ) { + unset( $handler, $raw_input ); + return static function () use ( $handler_request ): WP_Agent_Runtime_Package_Run_Result { + $GLOBALS['__runtime_package_handler_called'] = $handler_request->to_array(); + return new WP_Agent_Runtime_Package_Run_Result( + WP_Agent_Runtime_Package_Run_Result::STATUS_SUCCEEDED, + 'run-dispatch', + array( 'workflow_id' => $handler_request->get_workflow()['id'] ?? '' ), + array(), + array( array( 'type' => 'log', 'label' => 'runtime log' ) ) + ); + }; + }, + 10, + 3 +); + +$dispatch = AgentsAPI\AI\agents_runtime_package_run_dispatch( + array( + 'package' => array( 'slug' => 'site-builder' ), + 'workflow' => array( 'id' => 'build-site' ), + ) +); +agents_api_smoke_assert_equals( false, is_wp_error( $dispatch ), 'dispatcher returns handler output', $failures, $passes ); +agents_api_smoke_assert_equals( 'succeeded', is_array( $dispatch ) ? $dispatch['status'] ?? '' : '', 'dispatcher normalizes result status', $failures, $passes ); +agents_api_smoke_assert_equals( 'build-site', is_array( $dispatch ) ? $dispatch['result']['workflow_id'] ?? '' : '', 'dispatcher passes workflow to handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'runtime log', is_array( $dispatch ) ? $dispatch['evidence_refs'][0]['label'] ?? '' : '', 'dispatcher preserves evidence refs', $failures, $passes ); + +agents_api_smoke_finish( 'Agents API runtime package run contract', $failures, $passes );