From f8b32ec3ed9b7d9599c50a79dc5a79fa32d10334 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 25 Jun 2026 11:19:55 -0400 Subject: [PATCH 1/2] Document external chat run bridges --- composer.json | 1 + docs/channels-workflows-operations.md | 93 +++++- ...ster-agents-chat-run-control-abilities.php | 14 +- tests/chat-run-control-smoke.php | 35 +- .../homeboy-chat-run-bridge-fixture-smoke.php | 303 ++++++++++++++++++ 5 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 tests/homeboy-chat-run-bridge-fixture-smoke.php diff --git a/composer.json b/composer.json index e779ca8..7a85ccc 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,7 @@ "php tests/canonical-run-lifecycle-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", + "php tests/homeboy-chat-run-bridge-fixture-smoke.php", "php tests/task-execution-smoke.php", "php tests/frontend-chat-rest-smoke.php", "php tests/agents-chat-jsonrpc-route-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index 4382e60..ffe8aa0 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -72,11 +72,12 @@ Agents API owns the generic run-control ability contracts and default run-contro | --- | --- | --- | | `agents/get-chat-run` | Return status for a known chat run. | `wp_agent_chat_run_status_handler` | | `agents/cancel-chat-run` | Request best-effort cancellation for a known chat run. | `wp_agent_chat_run_cancel_handler` | +| `agents/list-chat-run-events` | Return lifecycle/event pages for a known chat run. | `wp_agent_chat_run_events_handler` | | `agents/queue-chat-message` | Accept a next user message while a session has an active run. | `wp_agent_chat_message_queue_handler` | Clients can expose status, Stop, and Queue controls whenever these canonical abilities are available and the caller has permission for the selected agent. The default handlers preserve safe behavior for synchronous runtimes; runtime-specific handlers are enhancements, not prerequisites. -Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, and `failed`. The canonical run payload is: +Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, `runtime_tool_pending`, `approval_required`, `budget_exceeded`, `stalled`, and `interrupted`. The canonical run payload is: ```php array( @@ -85,10 +86,98 @@ array( 'status' => 'running', 'started_at' => '2026-01-01T00:00:00Z', 'updated_at' => '2026-01-01T00:00:01Z', - 'metadata' => array(), + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'external-provider-id', + 'run_id' => 'provider-run-id', + 'event_cursor' => 'provider-event-cursor', + ), + ), +) +``` + +The status, events, and cancel hooks are the generic external durable-run adapter contract. A host can back them with any durable run provider by returning the canonical payloads above; Agents API normalizes status values, validates required identifiers, applies the existing permission callbacks, and keeps provider-specific state inside metadata. The canonical metadata object is `metadata['orchestration']` with `provider` for the adapter/provider id, `run_id` for the durable provider run id, and `event_cursor` for the provider cursor associated with the latest status or event page. These keys are intentionally generic and do not imply a specific runner, queue, or product. + +Event handlers return the same run fields plus an `events` list, `cursor`, and `has_more`. Each event uses `id`, `type`, `created_at`, optional `message`, and opaque `metadata`. The returned `cursor` is the client polling cursor for the next `agents/list-chat-run-events` call; adapters can mirror a provider-native cursor in `metadata['orchestration']['event_cursor']` when the provider cursor differs from the public cursor. + +### Homeboy-shaped durable run bridge + +A Homeboy bridge should call its own in-process adapter or service client for `homeboy/agent-task-run-status/v1` and then return the canonical Agents API run-control shape. Agents API does not shell out to Homeboy and does not require a Homeboy package dependency; the bridge is just a handler behind `wp_agent_chat_run_status_handler` and `wp_agent_chat_run_events_handler`. + +Mapping guidance: + +| Homeboy status field | Agents API field | +| --- | --- | +| `run.state` | `status`, mapped into the bounded Agents API vocabulary: `queued`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, `stalled`, or `interrupted`. | +| `run.id` | `metadata.orchestration.run_id`; keep the client-addressable chat `run_id` unchanged at the top level. | +| `totals` | `metadata.orchestration.totals`. | +| `latest_event_cursor` | `cursor` on event pages and `metadata.orchestration.event_cursor` on status/event payloads. | +| `normalized_events` | `events`, with each item using canonical `id`, `type`, `created_at`, `message`, and `metadata`. | +| `artifact_refs` | `metadata.orchestration.artifact_refs`. | + +Example Homeboy-shaped input: + +```php +array( + 'ability' => 'homeboy/agent-task-run-status/v1', + 'run' => array( + 'id' => 'hb-run-1', + 'state' => 'succeeded', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + ), + 'totals' => array( + 'events' => 2, + 'artifacts' => 1, + 'errors' => 0, + ), + 'latest_event_cursor' => 'hb-cursor-2', + 'normalized_events' => array( + array( + 'id' => 'hb-event-2', + 'cursor' => 'hb-cursor-2', + 'type' => 'artifact', + 'created_at' => '2026-06-25T12:03:00Z', + 'message' => 'Recorded transcript artifact.', + ), + ), + 'artifact_refs' => array( + array( + 'id' => 'artifact-transcript', + 'type' => 'transcript', + 'label' => 'Transcript', + ), + ), ) ``` +Canonical `agents/get-chat-run` output: + +```php +array( + 'run_id' => 'run-chat-1', + 'session_id' => 'session-homeboy-1', + 'status' => 'completed', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'homeboy', + 'ability' => 'homeboy/agent-task-run-status/v1', + 'run_id' => 'hb-run-1', + 'state' => 'succeeded', + 'event_cursor' => 'hb-cursor-2', + 'totals' => array( 'events' => 2, 'artifacts' => 1, 'errors' => 0 ), + 'artifact_refs' => array( + array( 'id' => 'artifact-transcript', 'type' => 'transcript', 'label' => 'Transcript' ), + ), + ), + ), +) +``` + +Canonical `agents/list-chat-run-events` output uses the same run fields, returns `events` from `normalized_events`, sets `cursor` to `latest_event_cursor`, and repeats the Homeboy cursor in `metadata.orchestration.event_cursor`. Cancellation stays handler-owned: register `wp_agent_chat_run_cancel_handler` only when the bridge can request cancellation through its durable provider; otherwise Agents API keeps the default local cancellation behavior. + Cancellation is best-effort. A runtime that can abort provider work immediately may do so; a runtime that cannot should mark the run `cancelling` and let its conversation loop stop at the next interrupt check. `WP_Agent_Chat_Run_Control::cancellation_interrupt_message()` builds the message shape expected by `WP_Agent_Conversation_Loop` `interrupt_source` callbacks. Read abilities return observer-safe run envelopes for non-operators. Stored run state may contain runtime diagnostics, package/workflow selectors, raw refs, provenance, output, or caller metadata for audit/debugging, but `agents/get-chat-run`, `agents/list-chat-run-events`, `agents/get-task-run`, `agents/get-runtime-package-run`, and `agents/list-runtime-package-run-events` redact high-risk keys unless the caller passes the explicit unredacted read gate. Managers keep full access by default; hosts can extend that path with `agents_chat_run_unredacted_read_permission`, `agents_task_unredacted_read_permission`, or `agents_runtime_package_run_unredacted_read_permission` while leaving broad read access observer-safe. diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 49f4b77..3c2b504 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -453,7 +453,10 @@ function agents_chat_run_output_schema(): array { ), 'started_at' => array( 'type' => 'string' ), 'updated_at' => array( 'type' => 'string' ), - 'metadata' => array( 'type' => 'object' ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Opaque run metadata. External durable-run adapters should use orchestration.provider, orchestration.run_id, and orchestration.event_cursor for provider identity, provider run identity, and latest event cursor.', + ), ), ); } @@ -486,8 +489,15 @@ function agents_chat_run_events_output_schema(): array { ), ), ), - 'cursor' => array( 'type' => 'string' ), + 'cursor' => array( + 'type' => 'string', + 'description' => 'Opaque cursor for the next events page. External durable-run adapters should also mirror the latest durable cursor in metadata.orchestration.event_cursor when useful for status polling.', + ), 'has_more' => array( 'type' => 'boolean' ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Opaque event-page metadata using the same orchestration.provider, orchestration.run_id, and orchestration.event_cursor convention as run status payloads.', + ), ), ); } diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index b2868ff..1b873c2 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -216,7 +216,15 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'status' => 'running', 'started_at' => '2026-01-01T00:00:00Z', 'updated_at' => '2026-01-01T00:00:01Z', - 'metadata' => array( 'provider' => 'test', 'token' => 'secret-token' ), + 'metadata' => array( + 'provider' => 'test', + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-run-1', + 'event_cursor' => 'external-cursor-1', + ), + 'token' => 'secret-token', + ), ), 10, 2 @@ -225,6 +233,9 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { $status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ); agents_api_smoke_assert_equals( 'running', $status['status'] ?? null, 'get-run normalizes status payload', $failures, $passes ); agents_api_smoke_assert_equals( 'test', $status['metadata']['provider'] ?? null, 'get-run preserves metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'fake-durable-runner', $status['metadata']['orchestration']['provider'] ?? null, 'get-run preserves external orchestration provider metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'get-run preserves external orchestration run id metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-cursor-1', $status['metadata']['orchestration']['event_cursor'] ?? null, 'get-run preserves external orchestration event cursor metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'secret-token', $status['metadata']['token'] ?? null, 'manager get-run preserves operator metadata', $failures, $passes ); $GLOBALS['__agents_api_smoke_caps']['manage_options'] = false; $observer_status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ); @@ -283,6 +294,13 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'run_id' => $input['run_id'], 'session_id' => $input['session_id'], 'status' => 'running', + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-events-run-1', + 'event_cursor' => 'external-event-cursor-1', + ), + ), 'events' => array( array( 'id' => 'evt_1', @@ -290,9 +308,14 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'message' => 'Calling client/tool...', 'created_at' => '2026-01-01T00:00:00Z', 'metadata' => array( - 'turn' => 1, - 'tool_name' => 'client/tool', - 'tool_call_id' => 'call-1', + 'turn' => 1, + 'tool_name' => 'client/tool', + 'tool_call_id' => 'call-1', + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-events-run-1', + 'event_cursor' => 'external-event-cursor-1', + ), ), ), ), @@ -314,6 +337,10 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { agents_api_smoke_assert_equals( 'session-events-1', $event_page['session_id'] ?? null, 'run events handler preserves session id', $failures, $passes ); agents_api_smoke_assert_equals( 'running', $event_page['status'] ?? null, 'run events handler normalizes status', $failures, $passes ); agents_api_smoke_assert_equals( 'evt_1', $event_page['cursor'] ?? null, 'run events handler returns cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 'fake-durable-runner', $event_page['metadata']['orchestration']['provider'] ?? null, 'run events handler preserves external orchestration provider metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-events-run-1', $event_page['metadata']['orchestration']['run_id'] ?? null, 'run events handler preserves external orchestration run id metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-event-cursor-1', $event_page['metadata']['orchestration']['event_cursor'] ?? null, 'run events handler preserves external orchestration event cursor metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'client/tool', $event_page['events'][0]['metadata']['tool_name'] ?? null, 'run events handler returns safe metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-event-cursor-1', $event_page['events'][0]['metadata']['orchestration']['event_cursor'] ?? null, 'run events handler returns external event cursor metadata', $failures, $passes ); agents_api_smoke_finish( 'chat run-control', $failures, $passes ); diff --git a/tests/homeboy-chat-run-bridge-fixture-smoke.php b/tests/homeboy-chat-run-bridge-fixture-smoke.php new file mode 100644 index 0000000..657f269 --- /dev/null +++ b/tests/homeboy-chat-run-bridge-fixture-smoke.php @@ -0,0 +1,303 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability ): bool { + return ! empty( $GLOBALS['__agents_api_smoke_caps'][ $capability ] ); + } +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id(): int { + return (int) ( $GLOBALS['__agents_api_smoke_user_id'] ?? 0 ); + } +} + +if ( ! function_exists( 'wp_has_ability_category' ) ) { + function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); + } +} + +if ( ! function_exists( 'wp_register_ability_category' ) ) { + function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; + } +} + +if ( ! function_exists( 'wp_has_ability' ) ) { + function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); + } +} + +if ( ! function_exists( 'wp_register_ability' ) ) { + function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $option, $default = false ) { + return $GLOBALS['__agents_api_smoke_options'][ $option ] ?? $default; + } +} + +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $option, $value, $autoload = null ): bool { + unset( $autoload ); + $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; + return true; + } +} + +agents_api_smoke_require_module(); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +$GLOBALS['__agents_api_smoke_caps'] = array( + 'read' => true, + 'manage_options' => true, +); +$GLOBALS['__agents_api_smoke_user_id'] = 123; + +/** + * Fake in-process Homeboy bridge fixture. It accepts the shape returned by a + * Homeboy agent-task run-status adapter without invoking Homeboy itself. + */ +final class Agents_API_Homeboy_Chat_Run_Bridge_Fixture { + /** @param array $homeboy_status Homeboy-shaped status payload. */ + public function __construct( private array $homeboy_status ) {} + + /** @param array $input Ability input. */ + public function get_run( array $input ): array { + $run = $this->homeboy_run(); + + return array( + 'run_id' => (string) ( $input['run_id'] ?? '' ), + 'session_id' => (string) ( $input['session_id'] ?? '' ), + 'status' => self::map_state( (string) ( $run['state'] ?? '' ) ), + 'started_at' => (string) ( $run['started_at'] ?? '' ), + 'updated_at' => (string) ( $run['updated_at'] ?? '' ), + 'metadata' => $this->metadata(), + ); + } + + /** @param array $input Ability input. */ + public function list_events( array $input ): array { + $events = array(); + foreach ( is_array( $this->homeboy_status['normalized_events'] ?? null ) ? $this->homeboy_status['normalized_events'] : array() as $event ) { + if ( is_array( $event ) ) { + $events[] = $this->map_event( $event ); + } + } + + $run = $this->get_run( $input ); + + return array_merge( + $run, + array( + 'events' => $events, + 'cursor' => (string) ( $this->homeboy_status['latest_event_cursor'] ?? '' ), + 'has_more' => (bool) ( $this->homeboy_status['has_more_events'] ?? false ), + ) + ); + } + + /** @return array */ + private function homeboy_run(): array { + return is_array( $this->homeboy_status['run'] ?? null ) ? $this->homeboy_status['run'] : array(); + } + + /** @return array */ + private function metadata(): array { + $run = $this->homeboy_run(); + + return array( + 'orchestration' => array( + 'provider' => 'homeboy', + 'ability' => 'homeboy/agent-task-run-status/v1', + 'run_id' => (string) ( $run['id'] ?? '' ), + 'state' => (string) ( $run['state'] ?? '' ), + 'event_cursor' => (string) ( $this->homeboy_status['latest_event_cursor'] ?? '' ), + 'totals' => is_array( $this->homeboy_status['totals'] ?? null ) ? $this->homeboy_status['totals'] : array(), + 'artifact_refs' => is_array( $this->homeboy_status['artifact_refs'] ?? null ) ? array_values( $this->homeboy_status['artifact_refs'] ) : array(), + ), + ); + } + + /** @param array $event Homeboy normalized event. */ + private function map_event( array $event ): array { + $cursor = (string) ( $event['cursor'] ?? $this->homeboy_status['latest_event_cursor'] ?? '' ); + + return array( + 'id' => (string) ( $event['id'] ?? $cursor ), + 'type' => (string) ( $event['type'] ?? 'run_event' ), + 'created_at' => (string) ( $event['created_at'] ?? '' ), + 'message' => (string) ( $event['message'] ?? '' ), + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'homeboy', + 'ability' => 'homeboy/agent-task-run-status/v1', + 'run_id' => (string) ( $this->homeboy_run()['id'] ?? '' ), + 'event_cursor' => $cursor, + ), + 'raw_type' => (string) ( $event['raw_type'] ?? '' ), + ), + ); + } + + private static function map_state( string $state ): string { + return match ( strtolower( trim( $state ) ) ) { + 'queued', 'pending' => 'queued', + 'running', 'active' => 'running', + 'cancelling' => 'cancelling', + 'cancelled' => 'cancelled', + 'succeeded', 'completed', 'success' => 'completed', + 'failed', 'error' => 'failed', + 'stalled' => 'stalled', + 'interrupted' => 'interrupted', + default => 'running', + }; + } +} + +$homeboy_status = array( + 'ability' => 'homeboy/agent-task-run-status/v1', + 'run' => array( + 'id' => 'hb-run-1', + 'state' => 'succeeded', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + ), + 'totals' => array( + 'events' => 2, + 'artifacts' => 2, + 'errors' => 0, + ), + 'latest_event_cursor' => 'hb-cursor-2', + 'normalized_events' => array( + array( + 'id' => 'hb-event-1', + 'cursor' => 'hb-cursor-1', + 'type' => 'log', + 'raw_type' => 'stdout', + 'created_at' => '2026-06-25T12:01:00Z', + 'message' => 'Started agent task.', + ), + array( + 'id' => 'hb-event-2', + 'cursor' => 'hb-cursor-2', + 'type' => 'artifact', + 'raw_type' => 'artifact.recorded', + 'created_at' => '2026-06-25T12:03:00Z', + 'message' => 'Recorded transcript artifact.', + ), + ), + 'artifact_refs' => array( + array( + 'id' => 'artifact-transcript', + 'type' => 'transcript', + 'label' => 'Transcript', + ), + array( + 'id' => 'artifact-bundle', + 'type' => 'bundle', + 'label' => 'Run bundle', + ), + ), +); + +$fixture = new Agents_API_Homeboy_Chat_Run_Bridge_Fixture( $homeboy_status ); + +add_filter( + 'wp_agent_chat_run_status_handler', + static fn() => array( $fixture, 'get_run' ), + 10, + 2 +); + +add_filter( + 'wp_agent_chat_run_events_handler', + static fn() => array( $fixture, 'list_events' ), + 10, + 2 +); + +$status = AgentsAPI\AI\Channels\agents_get_chat_run( + array( + 'session_id' => 'session-homeboy-1', + 'run_id' => 'run-chat-1', + ) +); + +agents_api_smoke_assert_equals( 'run-chat-1', $status['run_id'] ?? null, 'Homeboy bridge preserves Agents API run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'session-homeboy-1', $status['session_id'] ?? null, 'Homeboy bridge preserves Agents API session id', $failures, $passes ); +agents_api_smoke_assert_equals( 'completed', $status['status'] ?? null, 'Homeboy succeeded state maps to Agents API completed status', $failures, $passes ); +agents_api_smoke_assert_equals( 'homeboy', $status['metadata']['orchestration']['provider'] ?? null, 'Homeboy bridge marks orchestration provider', $failures, $passes ); +agents_api_smoke_assert_equals( 'homeboy/agent-task-run-status/v1', $status['metadata']['orchestration']['ability'] ?? null, 'Homeboy bridge records source ability', $failures, $passes ); +agents_api_smoke_assert_equals( 'hb-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'Homeboy bridge maps provider run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'succeeded', $status['metadata']['orchestration']['state'] ?? null, 'Homeboy bridge preserves provider state in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'hb-cursor-2', $status['metadata']['orchestration']['event_cursor'] ?? null, 'Homeboy bridge maps latest_event_cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 2, $status['metadata']['orchestration']['totals']['events'] ?? null, 'Homeboy bridge maps totals metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-bundle', $status['metadata']['orchestration']['artifact_refs'][1]['id'] ?? null, 'Homeboy bridge maps artifact refs metadata', $failures, $passes ); + +$events = AgentsAPI\AI\Channels\agents_list_chat_run_events( + array( + 'session_id' => 'session-homeboy-1', + 'run_id' => 'run-chat-1', + 'cursor' => 'hb-cursor-0', + ) +); + +agents_api_smoke_assert_equals( 'completed', $events['status'] ?? null, 'Homeboy event page maps run status', $failures, $passes ); +agents_api_smoke_assert_equals( 'hb-cursor-2', $events['cursor'] ?? null, 'Homeboy event page maps latest cursor', $failures, $passes ); +agents_api_smoke_assert_equals( false, $events['has_more'] ?? null, 'Homeboy event page maps has_more default', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $events['events'] ?? array() ), 'Homeboy event page maps normalized events', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact', $events['events'][1]['type'] ?? null, 'Homeboy event page preserves normalized event type', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact.recorded', $events['events'][1]['metadata']['raw_type'] ?? null, 'Homeboy event page keeps raw event type in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'hb-cursor-2', $events['events'][1]['metadata']['orchestration']['event_cursor'] ?? null, 'Homeboy event metadata maps event cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-transcript', $events['metadata']['orchestration']['artifact_refs'][0]['id'] ?? null, 'Homeboy event page maps artifact refs metadata', $failures, $passes ); + +$cancelled = AgentsAPI\AI\Channels\agents_cancel_chat_run( + array( + 'session_id' => 'session-homeboy-1', + 'run_id' => 'run-chat-1', + ) +); + +agents_api_smoke_assert_equals( true, $cancelled instanceof WP_Error, 'Homeboy bridge fixture does not claim cancellation without a cancel handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $cancelled instanceof WP_Error ? $cancelled->get_error_code() : null, 'Homeboy cancellation remains handler-owned', $failures, $passes ); + +agents_api_smoke_finish( 'Homeboy chat run bridge fixture', $failures, $passes ); From 5921b33ea946787b0e68486ebfa26553f37841db Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 25 Jun 2026 11:57:14 -0400 Subject: [PATCH 2/2] Keep chat run bridge docs provider agnostic --- composer.json | 2 +- docs/channels-workflows-operations.md | 30 ++--- ...xternal-chat-run-bridge-fixture-smoke.php} | 122 +++++++++--------- 3 files changed, 77 insertions(+), 77 deletions(-) rename tests/{homeboy-chat-run-bridge-fixture-smoke.php => external-chat-run-bridge-fixture-smoke.php} (56%) diff --git a/composer.json b/composer.json index 7a85ccc..6c028dc 100644 --- a/composer.json +++ b/composer.json @@ -93,7 +93,7 @@ "php tests/canonical-run-lifecycle-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", - "php tests/homeboy-chat-run-bridge-fixture-smoke.php", + "php tests/external-chat-run-bridge-fixture-smoke.php", "php tests/task-execution-smoke.php", "php tests/frontend-chat-rest-smoke.php", "php tests/agents-chat-jsonrpc-route-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index ffe8aa0..3bcf5c3 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -100,13 +100,13 @@ The status, events, and cancel hooks are the generic external durable-run adapte Event handlers return the same run fields plus an `events` list, `cursor`, and `has_more`. Each event uses `id`, `type`, `created_at`, optional `message`, and opaque `metadata`. The returned `cursor` is the client polling cursor for the next `agents/list-chat-run-events` call; adapters can mirror a provider-native cursor in `metadata['orchestration']['event_cursor']` when the provider cursor differs from the public cursor. -### Homeboy-shaped durable run bridge +### External durable-run bridge -A Homeboy bridge should call its own in-process adapter or service client for `homeboy/agent-task-run-status/v1` and then return the canonical Agents API run-control shape. Agents API does not shell out to Homeboy and does not require a Homeboy package dependency; the bridge is just a handler behind `wp_agent_chat_run_status_handler` and `wp_agent_chat_run_events_handler`. +An external orchestration bridge should call its own in-process adapter or service client for its provider-native durable run status, then return the canonical Agents API run-control shape. Agents API does not shell out to an orchestrator and does not require any orchestrator package dependency; the bridge is just a handler behind `wp_agent_chat_run_status_handler` and `wp_agent_chat_run_events_handler`. Mapping guidance: -| Homeboy status field | Agents API field | +| External status field | Agents API field | | --- | --- | | `run.state` | `status`, mapped into the bounded Agents API vocabulary: `queued`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, `stalled`, or `interrupted`. | | `run.id` | `metadata.orchestration.run_id`; keep the client-addressable chat `run_id` unchanged at the top level. | @@ -115,13 +115,13 @@ Mapping guidance: | `normalized_events` | `events`, with each item using canonical `id`, `type`, `created_at`, `message`, and `metadata`. | | `artifact_refs` | `metadata.orchestration.artifact_refs`. | -Example Homeboy-shaped input: +Example provider-native input shape: ```php array( - 'ability' => 'homeboy/agent-task-run-status/v1', + 'schema' => 'example-orchestrator/run-status/v1', 'run' => array( - 'id' => 'hb-run-1', + 'id' => 'external-run-1', 'state' => 'succeeded', 'started_at' => '2026-06-25T12:00:00Z', 'updated_at' => '2026-06-25T12:03:00Z', @@ -131,11 +131,11 @@ array( 'artifacts' => 1, 'errors' => 0, ), - 'latest_event_cursor' => 'hb-cursor-2', + 'latest_event_cursor' => 'provider-cursor-2', 'normalized_events' => array( array( - 'id' => 'hb-event-2', - 'cursor' => 'hb-cursor-2', + 'id' => 'provider-event-2', + 'cursor' => 'provider-cursor-2', 'type' => 'artifact', 'created_at' => '2026-06-25T12:03:00Z', 'message' => 'Recorded transcript artifact.', @@ -156,17 +156,17 @@ Canonical `agents/get-chat-run` output: ```php array( 'run_id' => 'run-chat-1', - 'session_id' => 'session-homeboy-1', + 'session_id' => 'session-external-1', 'status' => 'completed', 'started_at' => '2026-06-25T12:00:00Z', 'updated_at' => '2026-06-25T12:03:00Z', 'metadata' => array( 'orchestration' => array( - 'provider' => 'homeboy', - 'ability' => 'homeboy/agent-task-run-status/v1', - 'run_id' => 'hb-run-1', + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', + 'run_id' => 'external-run-1', 'state' => 'succeeded', - 'event_cursor' => 'hb-cursor-2', + 'event_cursor' => 'provider-cursor-2', 'totals' => array( 'events' => 2, 'artifacts' => 1, 'errors' => 0 ), 'artifact_refs' => array( array( 'id' => 'artifact-transcript', 'type' => 'transcript', 'label' => 'Transcript' ), @@ -176,7 +176,7 @@ array( ) ``` -Canonical `agents/list-chat-run-events` output uses the same run fields, returns `events` from `normalized_events`, sets `cursor` to `latest_event_cursor`, and repeats the Homeboy cursor in `metadata.orchestration.event_cursor`. Cancellation stays handler-owned: register `wp_agent_chat_run_cancel_handler` only when the bridge can request cancellation through its durable provider; otherwise Agents API keeps the default local cancellation behavior. +Canonical `agents/list-chat-run-events` output uses the same run fields, returns provider events mapped into the canonical event shape, sets `cursor` to the latest provider cursor, and repeats that cursor in `metadata.orchestration.event_cursor` when useful. Cancellation stays handler-owned: register `wp_agent_chat_run_cancel_handler` only when the bridge can request cancellation through its durable provider; otherwise Agents API keeps the default local cancellation behavior. Cancellation is best-effort. A runtime that can abort provider work immediately may do so; a runtime that cannot should mark the run `cancelling` and let its conversation loop stop at the next interrupt check. `WP_Agent_Chat_Run_Control::cancellation_interrupt_message()` builds the message shape expected by `WP_Agent_Conversation_Loop` `interrupt_source` callbacks. diff --git a/tests/homeboy-chat-run-bridge-fixture-smoke.php b/tests/external-chat-run-bridge-fixture-smoke.php similarity index 56% rename from tests/homeboy-chat-run-bridge-fixture-smoke.php rename to tests/external-chat-run-bridge-fixture-smoke.php index 657f269..28d7103 100644 --- a/tests/homeboy-chat-run-bridge-fixture-smoke.php +++ b/tests/external-chat-run-bridge-fixture-smoke.php @@ -1,8 +1,8 @@ $homeboy_status Homeboy-shaped status payload. */ - public function __construct( private array $homeboy_status ) {} +final class Agents_API_External_Chat_Run_Bridge_Fixture { + /** @param array $external_status Provider-native status payload. */ + public function __construct( private array $external_status ) {} /** @param array $input Ability input. */ public function get_run( array $input ): array { - $run = $this->homeboy_run(); + $run = $this->external_run(); return array( 'run_id' => (string) ( $input['run_id'] ?? '' ), @@ -117,7 +117,7 @@ public function get_run( array $input ): array { /** @param array $input Ability input. */ public function list_events( array $input ): array { $events = array(); - foreach ( is_array( $this->homeboy_status['normalized_events'] ?? null ) ? $this->homeboy_status['normalized_events'] : array() as $event ) { + foreach ( is_array( $this->external_status['normalized_events'] ?? null ) ? $this->external_status['normalized_events'] : array() as $event ) { if ( is_array( $event ) ) { $events[] = $this->map_event( $event ); } @@ -129,37 +129,37 @@ public function list_events( array $input ): array { $run, array( 'events' => $events, - 'cursor' => (string) ( $this->homeboy_status['latest_event_cursor'] ?? '' ), - 'has_more' => (bool) ( $this->homeboy_status['has_more_events'] ?? false ), + 'cursor' => (string) ( $this->external_status['latest_event_cursor'] ?? '' ), + 'has_more' => (bool) ( $this->external_status['has_more_events'] ?? false ), ) ); } /** @return array */ - private function homeboy_run(): array { - return is_array( $this->homeboy_status['run'] ?? null ) ? $this->homeboy_status['run'] : array(); + private function external_run(): array { + return is_array( $this->external_status['run'] ?? null ) ? $this->external_status['run'] : array(); } /** @return array */ private function metadata(): array { - $run = $this->homeboy_run(); + $run = $this->external_run(); return array( 'orchestration' => array( - 'provider' => 'homeboy', - 'ability' => 'homeboy/agent-task-run-status/v1', + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', 'run_id' => (string) ( $run['id'] ?? '' ), 'state' => (string) ( $run['state'] ?? '' ), - 'event_cursor' => (string) ( $this->homeboy_status['latest_event_cursor'] ?? '' ), - 'totals' => is_array( $this->homeboy_status['totals'] ?? null ) ? $this->homeboy_status['totals'] : array(), - 'artifact_refs' => is_array( $this->homeboy_status['artifact_refs'] ?? null ) ? array_values( $this->homeboy_status['artifact_refs'] ) : array(), + 'event_cursor' => (string) ( $this->external_status['latest_event_cursor'] ?? '' ), + 'totals' => is_array( $this->external_status['totals'] ?? null ) ? $this->external_status['totals'] : array(), + 'artifact_refs' => is_array( $this->external_status['artifact_refs'] ?? null ) ? array_values( $this->external_status['artifact_refs'] ) : array(), ), ); } - /** @param array $event Homeboy normalized event. */ + /** @param array $event external normalized event. */ private function map_event( array $event ): array { - $cursor = (string) ( $event['cursor'] ?? $this->homeboy_status['latest_event_cursor'] ?? '' ); + $cursor = (string) ( $event['cursor'] ?? $this->external_status['latest_event_cursor'] ?? '' ); return array( 'id' => (string) ( $event['id'] ?? $cursor ), @@ -168,9 +168,9 @@ private function map_event( array $event ): array { 'message' => (string) ( $event['message'] ?? '' ), 'metadata' => array( 'orchestration' => array( - 'provider' => 'homeboy', - 'ability' => 'homeboy/agent-task-run-status/v1', - 'run_id' => (string) ( $this->homeboy_run()['id'] ?? '' ), + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', + 'run_id' => (string) ( $this->external_run()['id'] ?? '' ), 'event_cursor' => $cursor, ), 'raw_type' => (string) ( $event['raw_type'] ?? '' ), @@ -193,10 +193,10 @@ private static function map_state( string $state ): string { } } -$homeboy_status = array( - 'ability' => 'homeboy/agent-task-run-status/v1', +$external_status = array( + 'schema' => 'example-orchestrator/run-status/v1', 'run' => array( - 'id' => 'hb-run-1', + 'id' => 'external-run-1', 'state' => 'succeeded', 'started_at' => '2026-06-25T12:00:00Z', 'updated_at' => '2026-06-25T12:03:00Z', @@ -206,23 +206,23 @@ private static function map_state( string $state ): string { 'artifacts' => 2, 'errors' => 0, ), - 'latest_event_cursor' => 'hb-cursor-2', + 'latest_event_cursor' => 'provider-cursor-2', 'normalized_events' => array( array( - 'id' => 'hb-event-1', - 'cursor' => 'hb-cursor-1', + 'id' => 'provider-event-1', + 'cursor' => 'provider-cursor-1', 'type' => 'log', 'raw_type' => 'stdout', 'created_at' => '2026-06-25T12:01:00Z', - 'message' => 'Started agent task.', + 'message' => 'Started durable work.', ), array( - 'id' => 'hb-event-2', - 'cursor' => 'hb-cursor-2', + 'id' => 'provider-event-2', + 'cursor' => 'provider-cursor-2', 'type' => 'artifact', 'raw_type' => 'artifact.recorded', 'created_at' => '2026-06-25T12:03:00Z', - 'message' => 'Recorded transcript artifact.', + 'message' => 'Recorded artifact.', ), ), 'artifact_refs' => array( @@ -239,7 +239,7 @@ private static function map_state( string $state ): string { ), ); -$fixture = new Agents_API_Homeboy_Chat_Run_Bridge_Fixture( $homeboy_status ); +$fixture = new Agents_API_External_Chat_Run_Bridge_Fixture( $external_status ); add_filter( 'wp_agent_chat_run_status_handler', @@ -257,47 +257,47 @@ private static function map_state( string $state ): string { $status = AgentsAPI\AI\Channels\agents_get_chat_run( array( - 'session_id' => 'session-homeboy-1', + 'session_id' => 'session-external-1', 'run_id' => 'run-chat-1', ) ); -agents_api_smoke_assert_equals( 'run-chat-1', $status['run_id'] ?? null, 'Homeboy bridge preserves Agents API run id', $failures, $passes ); -agents_api_smoke_assert_equals( 'session-homeboy-1', $status['session_id'] ?? null, 'Homeboy bridge preserves Agents API session id', $failures, $passes ); -agents_api_smoke_assert_equals( 'completed', $status['status'] ?? null, 'Homeboy succeeded state maps to Agents API completed status', $failures, $passes ); -agents_api_smoke_assert_equals( 'homeboy', $status['metadata']['orchestration']['provider'] ?? null, 'Homeboy bridge marks orchestration provider', $failures, $passes ); -agents_api_smoke_assert_equals( 'homeboy/agent-task-run-status/v1', $status['metadata']['orchestration']['ability'] ?? null, 'Homeboy bridge records source ability', $failures, $passes ); -agents_api_smoke_assert_equals( 'hb-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'Homeboy bridge maps provider run id', $failures, $passes ); -agents_api_smoke_assert_equals( 'succeeded', $status['metadata']['orchestration']['state'] ?? null, 'Homeboy bridge preserves provider state in metadata', $failures, $passes ); -agents_api_smoke_assert_equals( 'hb-cursor-2', $status['metadata']['orchestration']['event_cursor'] ?? null, 'Homeboy bridge maps latest_event_cursor', $failures, $passes ); -agents_api_smoke_assert_equals( 2, $status['metadata']['orchestration']['totals']['events'] ?? null, 'Homeboy bridge maps totals metadata', $failures, $passes ); -agents_api_smoke_assert_equals( 'artifact-bundle', $status['metadata']['orchestration']['artifact_refs'][1]['id'] ?? null, 'Homeboy bridge maps artifact refs metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'run-chat-1', $status['run_id'] ?? null, 'external orchestration bridge preserves Agents API run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'session-external-1', $status['session_id'] ?? null, 'external orchestration bridge preserves Agents API session id', $failures, $passes ); +agents_api_smoke_assert_equals( 'completed', $status['status'] ?? null, 'external orchestrator succeeded state maps to Agents API completed status', $failures, $passes ); +agents_api_smoke_assert_equals( 'example-orchestrator', $status['metadata']['orchestration']['provider'] ?? null, 'external orchestration bridge marks orchestration provider', $failures, $passes ); +agents_api_smoke_assert_equals( 'example-orchestrator/run-status/v1', $status['metadata']['orchestration']['schema'] ?? null, 'external orchestration bridge records source schema', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'external orchestration bridge maps provider run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'succeeded', $status['metadata']['orchestration']['state'] ?? null, 'external orchestration bridge preserves provider state in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $status['metadata']['orchestration']['event_cursor'] ?? null, 'external orchestration bridge maps latest_event_cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 2, $status['metadata']['orchestration']['totals']['events'] ?? null, 'external orchestration bridge maps totals metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-bundle', $status['metadata']['orchestration']['artifact_refs'][1]['id'] ?? null, 'external orchestration bridge maps artifact refs metadata', $failures, $passes ); $events = AgentsAPI\AI\Channels\agents_list_chat_run_events( array( - 'session_id' => 'session-homeboy-1', + 'session_id' => 'session-external-1', 'run_id' => 'run-chat-1', - 'cursor' => 'hb-cursor-0', + 'cursor' => 'provider-cursor-0', ) ); -agents_api_smoke_assert_equals( 'completed', $events['status'] ?? null, 'Homeboy event page maps run status', $failures, $passes ); -agents_api_smoke_assert_equals( 'hb-cursor-2', $events['cursor'] ?? null, 'Homeboy event page maps latest cursor', $failures, $passes ); -agents_api_smoke_assert_equals( false, $events['has_more'] ?? null, 'Homeboy event page maps has_more default', $failures, $passes ); -agents_api_smoke_assert_equals( 2, count( $events['events'] ?? array() ), 'Homeboy event page maps normalized events', $failures, $passes ); -agents_api_smoke_assert_equals( 'artifact', $events['events'][1]['type'] ?? null, 'Homeboy event page preserves normalized event type', $failures, $passes ); -agents_api_smoke_assert_equals( 'artifact.recorded', $events['events'][1]['metadata']['raw_type'] ?? null, 'Homeboy event page keeps raw event type in metadata', $failures, $passes ); -agents_api_smoke_assert_equals( 'hb-cursor-2', $events['events'][1]['metadata']['orchestration']['event_cursor'] ?? null, 'Homeboy event metadata maps event cursor', $failures, $passes ); -agents_api_smoke_assert_equals( 'artifact-transcript', $events['metadata']['orchestration']['artifact_refs'][0]['id'] ?? null, 'Homeboy event page maps artifact refs metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'completed', $events['status'] ?? null, 'external orchestrator event page maps run status', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $events['cursor'] ?? null, 'external orchestrator event page maps latest cursor', $failures, $passes ); +agents_api_smoke_assert_equals( false, $events['has_more'] ?? null, 'external orchestrator event page maps has_more default', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $events['events'] ?? array() ), 'external orchestrator event page maps normalized events', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact', $events['events'][1]['type'] ?? null, 'external orchestrator event page preserves normalized event type', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact.recorded', $events['events'][1]['metadata']['raw_type'] ?? null, 'external orchestrator event page keeps raw event type in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $events['events'][1]['metadata']['orchestration']['event_cursor'] ?? null, 'external orchestrator event metadata maps event cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-transcript', $events['metadata']['orchestration']['artifact_refs'][0]['id'] ?? null, 'external orchestrator event page maps artifact refs metadata', $failures, $passes ); $cancelled = AgentsAPI\AI\Channels\agents_cancel_chat_run( array( - 'session_id' => 'session-homeboy-1', + 'session_id' => 'session-external-1', 'run_id' => 'run-chat-1', ) ); -agents_api_smoke_assert_equals( true, $cancelled instanceof WP_Error, 'Homeboy bridge fixture does not claim cancellation without a cancel handler', $failures, $passes ); -agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $cancelled instanceof WP_Error ? $cancelled->get_error_code() : null, 'Homeboy cancellation remains handler-owned', $failures, $passes ); +agents_api_smoke_assert_equals( true, $cancelled instanceof WP_Error, 'external orchestration bridge fixture does not claim cancellation without a cancel handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $cancelled instanceof WP_Error ? $cancelled->get_error_code() : null, 'external orchestrator cancellation remains handler-owned', $failures, $passes ); -agents_api_smoke_finish( 'Homeboy chat run bridge fixture', $failures, $passes ); +agents_api_smoke_finish( 'external orchestrator chat run bridge fixture', $failures, $passes );