diff --git a/README.md b/README.md index fb71432..20e0e2e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Built on top of [`Automattic/agents-api`](https://github.com/Automattic/agents-a | **Chat block** (`wp:openclawp/chat`) | Drop the agent UI into any post, page, template, or wp-admin screen | ✅ | | **Chat ability** (`openclawp/chat`) | Callable from MCP servers, Studio Code skills, WP-CLI, other plugins — no HTTP needed | ✅ | | **Canonical dispatcher** (`agents/chat`) | Per [agents-api#100](https://github.com/Automattic/agents-api/issues/100); the runtime-agnostic contract for "chat with an agent" | ✅ | +| **Agent mesh (A2A)** | Every agent can publish an A2A **agent card** at `…/agenttic//.well-known/agent-card.json` (discovery; gated to `manage_options` by default, open it via filter), and any agent can call a remote peer as a tool. Peer calls carry the agents-api caller-chain headers ([#81](https://github.com/Automattic/agents-api/pull/81)) and are tagged `peer-agent` on receipt ([#180](https://github.com/Automattic/agents-api/pull/180)). See [`docs/a2a-studio-proposal.md`](docs/a2a-studio-proposal.md). | ✅ | | **REST endpoints** | `POST /openclawp/v1/chat` for browser UIs, `GET /openclawp/v1/chat/{session}` for transcripts | ✅ | | **Multi-turn sessions** | Each conversation is a CPT (`openclawp_session`); history follows the user across requests | ✅ | | **Tool use** | Agents can call read-only abilities — recent posts, comment counts, active plugins, `who-am-I` — bundled with the example agent | ✅ | @@ -282,6 +283,24 @@ Full troubleshooting + advanced flags: [`tools/wp-env/README.md`](tools/wp-env/R | `openclawp_wacli_skip_self_messages` | Per-request override of the from_me skip in the wacli channel. | | `openclawp_wacli_binary_candidates` | Add custom paths to wacli auto-discovery. | | `openclawp_channels` | Register additional Channels in the wp-admin Channels list view. | +| `openclawp_a2a_peers` | Register remote A2A peer agents. Each becomes an `a2a/` tool any local agent can call. Default `[]`. | +| `openclawp_a2a_client_permission` | Override the default `manage_options` gate on invoking A2A peer tools. | +| `openclawp_agent_card_permission` | Who can read an agent card. Default `manage_options` (the card exposes the system prompt + tools); return `true` for public A2A discovery. | + +**Wire two sites into a mesh** — on the orchestrator site, point an `a2a/` tool at a peer's bridge endpoint, then add that tool to an agent: + +```php +add_filter( 'openclawp_a2a_peers', function ( $peers ) { + $peers['site-b'] = array( + 'label' => 'Client Site B', + 'endpoint' => 'https://site-b.test/wp-json/openclawp/v1/agenttic/openclawp-site-introspection', + 'headers' => array( 'Authorization' => 'Bearer …' ), // optional + 'local_agent' => 'openclawp-coordinator', // caller-context attribution + ); + return $peers; +} ); +// Then add 'a2a/site-b' to an agent's default_config['tools'] — the loop calls it like any tool. +``` `openclawp_chat_turn_completed` fires after every chat turn with provider, model, token usage, and wall duration — grep `error_log` for `[openclawp] chat_turn=…`. @@ -291,7 +310,7 @@ Full troubleshooting + advanced flags: [`tools/wp-env/README.md`](tools/wp-env/R ```bash composer install -vendor/bin/phpunit # 41 unit tests +vendor/bin/phpunit # pure-PHP unit tests ``` Plus a smoke test that needs a running WP (any of the install paths above): @@ -311,6 +330,7 @@ End-to-end (REST → ability → `proc_open` → real WhatsApp) is exercised man - [`docs/local-ollama.md`](docs/local-ollama.md) — agent runbook for routing chat to a local Gemma via Ollama - [`docs/atomic-demo.md`](docs/atomic-demo.md) — WordPress.com Atomic deployment and agency demo runbook - [`docs/provider-precedence.md`](docs/provider-precedence.md) — recorded design for per-agent / per-site provider routing +- [`docs/a2a-studio-proposal.md`](docs/a2a-studio-proposal.md) — design for the agent mesh (A2A cards + peer client) and an A2A version of Studio --- diff --git a/docs/a2a-studio-proposal.md b/docs/a2a-studio-proposal.md new file mode 100644 index 0000000..6276811 --- /dev/null +++ b/docs/a2a-studio-proposal.md @@ -0,0 +1,225 @@ +# Proposal: an A2A version of Studio + +**Status:** Draft / discussion +**Author:** Miguel Lezama (with Claude) +**Date:** 2026-06-03 +**Audience:** openclaWP spike, agents-api, Studio/Orbit, AIOps (SecEx) + +> One-line: turn Studio from *one local WordPress + one chat* into a **mesh of +> WordPress agents** — a local Studio node orchestrating a fleet of disposable, +> sandboxed openclaWP peers that discover and call each other over the A2A +> protocol, each brain powered by Gemini (or any provider). + +--- + +## 1. Why now — every ingredient already exists + +This isn't greenfield. Five pieces landed independently in the last two months and +happen to compose into a multi-agent Studio. The only missing parts are the *seams* +between them. + +| Ingredient | What it actually is | State | +|---|---|---| +| **PHP sandbox (native)** | The **Native PHP runtime in Studio** — a classic PHP binary running the built-in web server, a drop-in replacement for Playground. Critically, *"native PHP will allow PHP to spawn any child process"* — unlike Playground's WASM. ([Native PHP launch plan](https://studioapp2.wordpress.com/2026/06/02/native-php-launch-plan/), [Call for testing](https://radicalupdates.wordpress.com/2026/05/22/native-php-binaries-in-studio-call-for-testing/), Studio 1.10.0-beta1 feature flag) | ✅ beta | +| **agents-api** | Canonical `agents/chat` + `agents/run-workflow` dispatchers, the loop runtime, and the workflow substrate. openclaWP is the WP consumer. | ✅ | +| **The "a2a PR"** | agents-api's **cross-site A2A substrate**: [`WP_Agent_Caller_Context`](https://github.com/Automattic/agents-api/pull/81) (canonical `X-Agents-Api-*` caller-chain headers, depth ceiling, host-owned trust boundary) + [peer-agent chat context](https://github.com/Automattic/agents-api/pull/180) (`caller_agent`, `caller_session_id`, `peer_agent_call`). | ✅ merged | +| **openclaWP A2A bridge** | `class-openclawp-agenttic-bridge.php` — A2A-shaped JSON-RPC: `message/send` + `message/stream` (real SSE), Task envelopes. Now joined by an **agent card** (`class-openclawp-agent-card.php`) and an **A2A client** (`class-openclawp-a2a-client-bridge.php` + `…-transport.php`). | ✅ Phase 1+2 shipped | +| **Proxied internal sandboxes (the RSM "boxes")** | **SecEx — Secure Execution Platform** (AIOps): runs agents + code in isolated **Firecracker microVMs built on E2B**, reachable through the SecEx **Sandbox Client Proxy** (`sandbox.a8c.com`, `api-sandbox.a8c.com`). Two patterns: **Agent-in-Sandbox** and **Sandbox-as-a-skill**. **openclaWP is explicitly named as an intended consumer.** ([Secure Execution Platform](https://aioperations.wordpress.com/2026/04/07/secure-execution-platform/), [Systems Update 221](https://thursdayupdates.wordpress.com/2026/05/01/systems-update-221/), [SecEx Roles](https://systemsrequests.wordpress.com/2026/03/06/secex-roles/)) | ✅ shipped (Automattician-only) | +| **Gemini** | Per-agent provider routing via the WP AI client — already a swap-in next to Anthropic/OpenAI. | ✅ | + +**The thesis:** A2A is the wire, native-PHP openclaWP is the node, SecEx is the +fleet, agents-api supplies the trust/caller-chain, Gemini is the brain. Studio +becomes the *orchestrator console* for the mesh. + +--- + +## 2. What "A2A version of Studio" means concretely + +Today: open Studio → one local WP site → one chat with one agent. + +Proposed: open Studio → a **roster of agents**, each backed by its own WordPress +site. Some live locally (native-PHP Studio sites); some are spun up on demand as +disposable SecEx microVMs. You talk to an **orchestrator agent**; it *discovers* +peers by their agent card and *delegates* sub-tasks to them over A2A. Each peer is +a full openclaWP install with its own tools, content, and persona. Gemini powers +the loop on each node. + +Example flow (the demo we'd record): + +1. In Studio, ask the orchestrator: *"Audit these three client sites and draft a + migration plan."* +2. Orchestrator reads its peer roster (three agent cards), opens an A2A + `message/stream` to each peer, carrying `X-Agents-Api-*` caller context. +3. Each peer is a SecEx microVM running native-PHP WP + openclaWP + the target + site's content; it runs its own audit abilities locally and streams findings + back. +4. Orchestrator synthesizes, persists the run via the workflow Run Recorder, and + shows a per-peer trace in wp-admin. +5. Sandboxes evaporate (<1h TTL). The transcript and artifacts persist. + +--- + +## 3. Architecture + +``` +┌──────────────────────────────────────────┐ +│ Studio (Native PHP runtime) │ +│ ── openclaWP = ORCHESTRATOR node │ +│ • A2A client (NEW) │ +│ • SecEx connector (NEW) │ +│ • workflow step: "call peer over A2A" │ +│ • Gemini-backed loop │ +└───────────────┬───────────────────────────┘ + │ A2A: message/send + message/stream + │ headers: X-Agents-Api-Caller-* (#81) + │ client_context.source = peer-agent (#180) + │ (outbound via SecEx Sandbox Client Proxy) + ┌───────┴───────────────┬───────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ SecEx microVM │ │ SecEx microVM │ │ Local Studio │ +│ Firecracker │ │ Firecracker │ │ site (native │ +│ + native PHP │ │ + native PHP │ │ PHP) peer │ +│ + openclaWP │ │ + openclaWP │ │ + openclaWP │ +│ A2A bridge ✅ │ │ A2A bridge ✅ │ │ A2A bridge ✅ │ +│ agent-card 🆕 │ │ agent-card 🆕 │ │ agent-card 🆕 │ +└───────────────┘ └───────────────┘ └───────────────┘ + (disposable, <1h, no ingress) (persistent) +``` + +### Roles + +- **Orchestrator node** — a native-PHP Studio site running openclaWP. New + capability: it is an A2A *client* (today openclaWP is only an A2A *server*). +- **Peer nodes** — full openclaWP installs reachable over A2A. Two homes: + - **Local** Studio sites (native PHP) — persistent, for development/demo. + - **SecEx microVMs** — disposable, for fan-out and untrusted/parallel work. +- **agents-api** — supplies the canonical dispatchers and the cross-site + caller-chain primitives (#81/#180) that make agent→agent calls auditable and + depth-bounded. + +### Why native PHP matters here + +Playground's WASM **can't spawn host binaries** — the README already calls this out +as the reason the WhatsApp/wacli connector needs wp-env instead. Native PHP lifts +that ceiling: an openclaWP peer can shell out (wacli, CLI tools, `proc_open`), +which is what makes a peer a *real* agent node rather than a toy. This is the +single biggest unlock and it just shipped in Studio beta. + +### Mapping to SecEx's two patterns + +SecEx defines two architectural patterns; the mesh uses **both**: + +- **Agent-in-Sandbox** → a peer openclaWP *is* the agent, living inside the microVM, + driven over the network. This is the A2A peer node. +- **Sandbox-as-a-skill** → the orchestrator treats "spin up a peer and ask it X" as + a *tool call*. This is how the orchestrator provisions ephemeral peers. + +### The hard constraint: SecEx has no ingress and is short-lived + +From the SecEx docs: sandboxes are **disposable, stateless, <1h, and have no +ingress** — you can't open an inbound port *to* a sandbox; the orchestrator reaches +it through the **Sandbox Client Proxy** (`sandbox.a8c.com` / envd command channel). +This shapes the design: + +- **Direction:** the orchestrator always *initiates* (sandbox-as-skill). A2A + `message/send` / `message/stream` from orchestrator → peer flows over the SecEx + client proxy, not a public URL on the sandbox. +- **Lifetime:** A2A tasks against SecEx peers must be short. For anything longer + than the sandbox TTL, the orchestrator owns persistence (workflow Run Recorder) + and the sandbox is fire-and-collect. +- **Peer-initiated calls** (sandbox → orchestrator) ride the envd return channel, + not an inbound HTTP request. v0 can skip these. + +--- + +## 4. Gap analysis — what we actually build + +Most of this is *seams*, not new subsystems. Each maps to an existing openclaWP +pattern so it stays idiomatic. + +| # | Gap | Build | Mirrors existing pattern | Status | +|---|---|---|---|---| +| 1 | **No discovery** | Agent-card endpoint: `GET /openclawp/v1/agenttic//.well-known/agent-card.json` (skills, capabilities, A2A endpoint). | A2A spec / existing REST registration | ✅ `class-openclawp-agent-card.php` | +| 2 | **openclaWP can't call peers** | **A2A client bridge** — openclaWP as an A2A *consumer*: register each peer as an `a2a/` tool, open `message/send`, attach `X-Agents-Api-*` caller context. | `class-openclawp-mcp-client-bridge.php` (does exactly this shape for MCP) | ✅ `class-openclawp-a2a-client-bridge.php` + `…-transport.php` | +| 3 | **No way to provision peers** | **SecEx connector** — sandbox-as-a-skill: create microVM, push openclaWP + site, drive over the client proxy, collect, tear down. | `class-openclawp-mcp-client-transport.php` + Channels connector pattern | ⏳ Phase 3 | +| 4 | **Bridge is fire-and-forget** | Add `tasks/get` + cancellation to the bridge (currently v0-scoped out) for tasks that outlive a single sync call. | extend `class-openclawp-agenttic-bridge.php` | ⏳ Phase 4 | +| 5 | **No delegation primitive** | Workflow step type: *"call peer agent over A2A"* — so a deterministic recipe can fan out to peers and collect. | `agents/run-workflow` + `${steps.y.output.z}` bindings | ⏳ Phase 4 | +| 6 | **Caller chain not wired** | Thread agents-api #81/#180 (`X-Agents-Api-Caller-*`, `peer-agent` source, depth ceiling) through the bridge on both send and receive. | agents-api substrate already merged | ✅ send (transport) + receive (bridge `client_context`) | + +Already done / free: the A2A wire shape, SSE streaming, Gemini routing, run +persistence, the trust/caller-chain primitives. + +**Phase 1 + 2 are now implemented** (this PR): the agent card, the A2A client +bridge (peers configured via the `openclawp_a2a_peers` filter, each surfaced as +an `a2a/` tool), the outbound transport with caller-chain headers, and the +receive-side `peer-agent` tagging on the bridge. Covered by `tests/unit/` +(AgentCard, A2aClientBridge, A2aClientTransport) and a `tests/smoke.php` section +that fetches a live card and registers a peer ability. Remaining: Phase 3 (SecEx +connector) and Phase 4 (`tasks/get` + workflow "call peer" step). + +--- + +## 5. Phasing (small, parallel PRs — not one mega-PR) + +**Phase 0 — Proposal & alignment (this doc).** Confirm with Studio (native PHP +runtime API surface) and AIOps (SecEx access model, openclaWP onboarding). Decide +substrate for the first demo (local Studio mesh vs SecEx fleet). + +**Phase 1 — Discovery (#1).** Agent-card endpoint + a `wp openclawp agent-card` +CLI. Pure additive, no client yet. *One PR.* + +**Phase 2 — A2A client (#2, #6).** openclaWP-as-A2A-client bridge, caller-context +headers wired. Demo: two **local** native-PHP Studio sites where agent A delegates +to agent B. No SecEx yet — proves the mesh on localhost. *One PR.* + +**Phase 3 — SecEx connector (#3).** Sandbox-as-a-skill: orchestrator spins up one +ephemeral openclaWP peer in a microVM and delegates to it. Gated behind a config +flag + Automattician auth. *One PR, depends on Phase 2.* + +**Phase 4 — Orchestration & longevity (#4, #5).** Workflow "call peer" step + +`tasks/get`/cancel. End-to-end recorded demo: orchestrator + N peers + Gemini, with +per-peer trace. *One PR.* + +Each phase is independently shippable and demoable. Phase 2 alone is a compelling +"agents talking to agents inside WordPress" demo with zero external infra. + +--- + +## 6. Open questions (need input before building past Phase 1) + +1. **SecEx access for openclaWP** — what's the onboarding path? The SecEx posts name + openclaWP as a consumer but there's a note about "no ETA for wpcom sandboxes on + SecEx." Who owns the openclaWP→SecEx integration, and is there an SDK/API today + (the n8n SecEx nodes suggest a usable control surface)? +2. **Native PHP runtime API** — does Studio expose a programmatic way to launch N + sites with a blueprint, or is it GUI/CLI only? The mesh orchestrator needs to + provision local peers. +3. **Auth between peers** — A2A peers need credentials. Reuse openclaWP's OAuth 2.1 + server (`ship/issue-45-oauth-mcp`) for peer-to-peer, or lean on the agents-api + token authenticator + caller-context trust boundary? +4. **Demo substrate first** — local-only native-PHP mesh (Phase 2, zero infra) or + go straight to SecEx fleet? Recommend local first; it de-risks everything except + the SecEx connector. +5. **Studio UX** — is the "roster of agents" a Studio-app feature, or does it live + entirely in openclaWP's wp-admin (Channels-style list) for the spike? + +--- + +## 7. Recommendation + +Build **Phase 1 + Phase 2** now: agent card + A2A client, demoed as two local +native-PHP Studio sites delegating to each other with Gemini. It needs **zero +external infrastructure**, proves the entire thesis, and produces a shareable demo. +Treat SecEx (Phase 3) as the scale-out step once the local mesh works and the AIOps +onboarding path is confirmed. + +--- + +### Source index + +- Native PHP runtime — https://studioapp2.wordpress.com/2026/06/02/native-php-launch-plan/ · https://radicalupdates.wordpress.com/2026/05/22/native-php-binaries-in-studio-call-for-testing/ +- SecEx — https://aioperations.wordpress.com/2026/04/07/secure-execution-platform/ · https://thursdayupdates.wordpress.com/2026/05/01/systems-update-221/ · https://systemsrequests.wordpress.com/2026/03/06/secex-roles/ · https://aip2.wordpress.com/2026/02/25/what-if-we-hosted-the-agents-not-just-the-sites/ +- Agent Sandbox (RSM) — https://radicalupdates.wordpress.com/2026/04/17/agent-sandbox-extended-linear/ +- agents-api A2A substrate — https://github.com/Automattic/agents-api/pull/81 · https://github.com/Automattic/agents-api/pull/180 +- openclaWP A2A bridge — `includes/class-openclawp-agenttic-bridge.php` diff --git a/includes/class-openclawp-a2a-client-bridge.php b/includes/class-openclawp-a2a-client-bridge.php new file mode 100644 index 0000000..0e73747 --- /dev/null +++ b/includes/class-openclawp-a2a-client-bridge.php @@ -0,0 +1,230 @@ +` whose + * `execute_callback` opens an outbound `message/send` to that peer's bridge + * endpoint (via {@see OpenclaWP_A2a_Client_Transport}) and returns the peer's + * reply. That turns "ask another WordPress agent" into a tool any local agent + * can call — the same shape as a bridged MCP tool or a subagent delegate. + * + * Peers are configured through the `openclawp_a2a_peers` filter rather than a + * CPT/admin UI — this is the Phase 2 (code-first) substrate for the local + * native-PHP mesh demo (two Studio sites delegating to each other). A peer + * config is: + * + * add_filter( 'openclawp_a2a_peers', function ( $peers ) { + * $peers['site-b'] = array( + * 'label' => 'Client Site B', + * 'endpoint' => 'https://site-b.test/wp-json/openclawp/v1/agenttic/openclawp-site-introspection', + * 'headers' => array( 'Authorization' => 'Bearer …' ), // optional + * 'local_agent' => 'openclawp-coordinator', // optional; caller-context attribution + * ); + * return $peers; + * } ); + * + * @package OpenclaWP + * @since 0.7.0 + */ + +defined( 'ABSPATH' ) || exit; + +final class OpenclaWP_A2a_Client_Bridge { + + public const ABILITY_PREFIX = 'a2a/'; + public const ABILITY_CATEGORY = 'openclawp-a2a-peers'; + + public static function register(): void { + add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'register_category' ) ); + add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_bridged_abilities' ) ); + } + + public static function register_category(): void { + if ( ! function_exists( 'wp_register_ability_category' ) ) { + return; + } + if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::ABILITY_CATEGORY ) ) { + return; + } + wp_register_ability_category( + self::ABILITY_CATEGORY, + array( + 'label' => __( 'openclaWP A2A peers', 'openclawp' ), + 'description' => __( 'Remote agents reachable over the A2A protocol, registered as delegate tools.', 'openclawp' ), + ) + ); + } + + /** + * Configured peers, keyed by slug. Read from the `openclawp_a2a_peers` + * filter. Each value is an array with at least an `endpoint`. + * + * @return array> + */ + public static function peers(): array { + /** + * Filters the registered A2A peer agents. + * + * @since 0.7.0 + * + * @param array> $peers Peer configs keyed by slug. + */ + /** @var mixed $peers A filter can return anything; normalise to an array. */ + $peers = apply_filters( 'openclawp_a2a_peers', array() ); + return is_array( $peers ) ? $peers : array(); + } + + public static function register_bridged_abilities(): void { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + $plan = self::plan_peer_list( self::peers() ); + foreach ( $plan as $entry ) { + self::register_peer_ability( $entry ); + } + } + + /** + * Reduce the raw peer config map into a validated list of ability plans. + * Pure-PHP — no registration, no HTTP — so tests can assert the prefix + * wiring and validation without standing up WordPress. + * + * Peers missing a slug or a usable endpoint URL are dropped. + * + * @param array> $peers Raw peer config map. + * + * @return array,local_agent:string}> + */ + public static function plan_peer_list( array $peers ): array { + $plan = array(); + foreach ( $peers as $raw_slug => $config ) { + if ( ! is_array( $config ) ) { + continue; + } + + $slug = sanitize_title( (string) $raw_slug ); + if ( '' === $slug ) { + continue; + } + + $endpoint = isset( $config['endpoint'] ) ? esc_url_raw( (string) $config['endpoint'] ) : ''; + if ( '' === $endpoint ) { + continue; + } + + $headers = array(); + if ( isset( $config['headers'] ) && is_array( $config['headers'] ) ) { + foreach ( $config['headers'] as $name => $value ) { + $headers[ (string) $name ] = (string) $value; + } + } + + $plan[] = array( + 'ability_name' => self::ABILITY_PREFIX . $slug, + 'slug' => $slug, + 'label' => isset( $config['label'] ) && '' !== (string) $config['label'] ? (string) $config['label'] : $slug, + 'endpoint' => $endpoint, + 'headers' => $headers, + 'local_agent' => isset( $config['local_agent'] ) ? sanitize_title( (string) $config['local_agent'] ) : '', + ); + } + return $plan; + } + + /** + * Register one peer as an `a2a/` ability. + * + * @param array{ability_name:string,slug:string,label:string,endpoint:string,headers:array,local_agent:string} $entry Plan entry. + */ + private static function register_peer_ability( array $entry ): void { + $ability_name = $entry['ability_name']; + if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_name ) ) { + return; + } + + $endpoint = $entry['endpoint']; + $headers = $entry['headers']; + $local_agent = $entry['local_agent']; + $label = $entry['label']; + + wp_register_ability( + $ability_name, + array( + 'label' => sprintf( + /* translators: %s: peer agent label. */ + __( 'Delegate to %s (A2A)', 'openclawp' ), + $label + ), + 'description' => sprintf( + /* translators: %s: peer agent label. */ + __( 'Send a message to the remote agent "%s" over A2A and return its reply. Include all the context the remote agent needs in the prompt — it does not see this conversation.', 'openclawp' ), + $label + ), + 'category' => self::ABILITY_CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'prompt' ), + 'properties' => array( + 'prompt' => array( + 'type' => 'string', + 'description' => 'The message to send to the remote agent.', + ), + 'session_id' => array( + 'type' => 'string', + 'description' => 'Optional remote session id to continue a prior exchange with this peer.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + 'execute_callback' => static function ( array $args = array() ) use ( $endpoint, $headers, $local_agent ) { + return self::execute_peer_call( $endpoint, $headers, $local_agent, $args ); + }, + 'permission_callback' => static function (): bool { + /** + * Filters whether the current user may invoke A2A peer tools. + * + * @since 0.7.0 + * + * @param bool $allowed Default: manage_options. + */ + return (bool) apply_filters( 'openclawp_a2a_client_permission', current_user_can( 'manage_options' ) ); + }, + 'meta' => array( + 'effect' => class_exists( 'OpenclaWP_Tool_Effects' ) ? OpenclaWP_Tool_Effects::EFFECT_EXTERNAL : 'external', + ), + ) + ); + } + + /** + * Execute a single peer call: forward the prompt over A2A and surface the + * reply. Errors come back as WP_Errors so the loop's tool-call mediator can + * degrade gracefully ("the remote agent is unavailable…"). + * + * @param string $endpoint Peer JSON-RPC endpoint. + * @param array $headers Auth headers for the peer. + * @param string $local_agent Caller agent slug for caller-context attribution. + * @param array $args Tool-call arguments (`prompt`, optional `session_id`). + * + * @return array|\WP_Error + */ + public static function execute_peer_call( string $endpoint, array $headers, string $local_agent, array $args ) { + $prompt = isset( $args['prompt'] ) ? (string) $args['prompt'] : ''; + if ( '' === trim( $prompt ) ) { + return new \WP_Error( 'a2a_client_empty_prompt', 'prompt is required' ); + } + $session_id = isset( $args['session_id'] ) && '' !== (string) $args['session_id'] ? (string) $args['session_id'] : null; + + $caller = array( + 'agent' => $local_agent, + 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, + ); + + return OpenclaWP_A2a_Client_Transport::send_message( $endpoint, $prompt, $session_id, $caller, $headers ); + } +} diff --git a/includes/class-openclawp-a2a-client-transport.php b/includes/class-openclawp-a2a-client-transport.php new file mode 100644 index 0000000..e9299c6 --- /dev/null +++ b/includes/class-openclawp-a2a-client-transport.php @@ -0,0 +1,245 @@ + $headers Optional auth headers. + * + * @return array|\WP_Error + */ + public static function fetch_card( string $card_url, array $headers = array() ) { + if ( '' === $card_url ) { + return new \WP_Error( 'a2a_client_no_card_url', 'agent card url is required' ); + } + + $response = wp_remote_get( + $card_url, + array( + 'timeout' => self::READ_TIMEOUT_SECONDS, + 'headers' => array_merge( array( 'Accept' => 'application/json' ), $headers ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + if ( $code < 200 || $code >= 300 ) { + return new \WP_Error( 'a2a_client_card_http_status', sprintf( 'agent card returned HTTP %d: %s', $code, substr( $body, 0, 200 ) ) ); + } + + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) ) { + return new \WP_Error( 'a2a_client_card_bad_json', 'agent card returned non-JSON body' ); + } + + return $decoded; + } + + /** + * Send a single text message to a peer agent and return its reply. + * + * @param string $endpoint Absolute URL of the peer's JSON-RPC message endpoint. + * @param string $text User-message text to send. + * @param string|null $session_id Optional peer-side session to continue. + * @param array $caller Caller-context descriptor: keys `agent`, `user_id`, `session_id`, `chain_depth`, `chain_root`. + * @param array $headers Optional extra headers (e.g. Authorization). + * + * @return array{reply:string,session_id:string,task:array}|\WP_Error + */ + public static function send_message( string $endpoint, string $text, ?string $session_id, array $caller = array(), array $headers = array() ) { + if ( '' === $endpoint ) { + return new \WP_Error( 'a2a_client_no_endpoint', 'peer endpoint url is required' ); + } + if ( '' === trim( $text ) ) { + return new \WP_Error( 'a2a_client_empty_message', 'message text is required' ); + } + + $params = array( + 'id' => 'task-' . wp_generate_password( 12, false, false ), + 'message' => array( + 'role' => 'user', + 'parts' => array( + array( + 'type' => 'text', + 'text' => $text, + ), + ), + 'messageId' => wp_generate_password( 12, false, false ), + 'kind' => 'message', + ), + ); + if ( null !== $session_id && '' !== $session_id ) { + $params['sessionId'] = $session_id; + } + + $request_headers = array_merge( + array( + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ), + self::caller_headers( $caller ), + $headers + ); + + $response = wp_remote_post( + $endpoint, + array( + 'timeout' => self::READ_TIMEOUT_SECONDS, + 'headers' => $request_headers, + 'body' => wp_json_encode( + array( + 'jsonrpc' => '2.0', + 'id' => 'req-' . wp_generate_password( 8, false, false ), + 'method' => 'message/send', + 'params' => $params, + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + if ( $code < 200 || $code >= 300 ) { + return new \WP_Error( 'a2a_client_http_status', sprintf( 'peer returned HTTP %d: %s', $code, substr( $body, 0, 200 ) ) ); + } + + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) ) { + return new \WP_Error( 'a2a_client_bad_json', 'peer returned non-JSON body' ); + } + + return self::parse_task_response( $decoded ); + } + + /** + * Parse an A2A JSON-RPC response envelope into the reply/session/task + * triple. Pure — exposed for unit tests. + * + * @param array $decoded Decoded JSON-RPC envelope. + * + * @return array{reply:string,session_id:string,task:array}|\WP_Error + */ + public static function parse_task_response( array $decoded ) { + if ( isset( $decoded['error'] ) && is_array( $decoded['error'] ) ) { + $message = isset( $decoded['error']['message'] ) ? (string) $decoded['error']['message'] : 'unknown error'; + return new \WP_Error( 'a2a_client_rpc_error', sprintf( 'peer error: %s', $message ) ); + } + + $task = isset( $decoded['result'] ) && is_array( $decoded['result'] ) ? $decoded['result'] : array(); + if ( empty( $task ) ) { + return new \WP_Error( 'a2a_client_no_result', 'peer response had no result' ); + } + + $reply = ''; + $status = isset( $task['status'] ) && is_array( $task['status'] ) ? $task['status'] : array(); + $message = isset( $status['message'] ) && is_array( $status['message'] ) ? $status['message'] : array(); + $parts = isset( $message['parts'] ) && is_array( $message['parts'] ) ? $message['parts'] : array(); + foreach ( $parts as $part ) { + if ( is_array( $part ) && 'text' === ( $part['type'] ?? '' ) && isset( $part['text'] ) ) { + $reply = (string) $part['text']; + break; + } + } + + return array( + 'reply' => $reply, + 'session_id' => isset( $task['sessionId'] ) ? (string) $task['sessionId'] : '', + 'task' => $task, + ); + } + + /** + * Build the canonical cross-site caller-chain headers for an outbound call. + * Pure — exposed for unit tests. + * + * A call originating in this site is one hop above top-of-chain, so the + * peer receives `chain_depth >= 1` with this site as the remote caller + * host. When this site is itself mid-chain (it was called by another + * agent), pass the inbound `chain_depth` / `chain_root` through `$caller` + * so the depth ceiling stays meaningful end-to-end. + * + * @param array $caller Keys: `agent`, `user_id`, `chain_depth`, `chain_root`. + * + * @return array + */ + public static function caller_headers( array $caller ): array { + $agent = isset( $caller['agent'] ) ? (string) $caller['agent'] : ''; + if ( '' === $agent ) { + // Without a caller agent slug there is no auditable chain to send; + // let the call go out as an anonymous top-of-chain request. + return array(); + } + + $inbound_depth = isset( $caller['chain_depth'] ) ? max( 0, (int) $caller['chain_depth'] ) : 0; + $depth = $inbound_depth + 1; + $root = isset( $caller['chain_root'] ) && '' !== (string) $caller['chain_root'] + ? (string) $caller['chain_root'] + : self::generate_request_id(); + $host = function_exists( 'home_url' ) ? (string) home_url() : ''; + $user_id = isset( $caller['user_id'] ) ? max( 0, (int) $caller['user_id'] ) : 0; + + return array( + self::HEADER_CALLER_AGENT => $agent, + self::HEADER_CALLER_USER => (string) $user_id, + self::HEADER_CALLER_HOST => $host, + self::HEADER_CHAIN_DEPTH => (string) $depth, + self::HEADER_CHAIN_ROOT => $root, + ); + } + + private static function generate_request_id(): string { + if ( function_exists( 'wp_generate_uuid4' ) ) { + return wp_generate_uuid4(); + } + return 'req-' . wp_generate_password( 24, false, false ); + } +} diff --git a/includes/class-openclawp-agent-card.php b/includes/class-openclawp-agent-card.php new file mode 100644 index 0000000..471e443 --- /dev/null +++ b/includes/class-openclawp-agent-card.php @@ -0,0 +1,263 @@ +/.well-known/agent-card.json + * + * The card is the discovery document a peer reads *before* opening a + * conversation: it advertises the agent's name, the JSON-RPC endpoint the + * {@see OpenclaWP_Agenttic_Bridge} exposes (`message/send` / `message/stream`), + * the transport capabilities, and a list of skills derived from the agent's + * configured tools and subagents. It is the missing half of the bridge — the + * bridge could already *answer* A2A calls, but nothing told a peer the agent + * existed or what it could do. + * + * The card mirrors the bridge's own permission gate (`manage_options`) by + * default: an agent's description doubles as its system prompt, so a wide-open + * card would leak the prompt and full tool inventory to anonymous callers, and + * the card should never be more permissive than the endpoint it advertises. + * Sites that want true public A2A discovery can opt in with the + * `openclawp_agent_card_permission` filter (return `true`). + * + * Pairs with the A2A client side ({@see OpenclaWP_A2a_Client_Bridge}) which + * reads these cards to call peers. + * + * @package OpenclaWP + * @since 0.7.0 + */ + +defined( 'ABSPATH' ) || exit; + +final class OpenclaWP_Agent_Card { + + private const NAMESPACE = 'openclawp/v1'; + + /** + * A2A protocol version advertised by the bridge. The agenttic-client wire + * (`message/send` + `message/stream`, Task envelopes) tracks A2A 0.2.x. + */ + private const PROTOCOL_VERSION = '0.2.5'; + + public static function register(): void { + add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); + } + + public static function register_routes(): void { + register_rest_route( + self::NAMESPACE, + '/agenttic/(?P[A-Za-z0-9_\-]+)/\.well-known/agent-card\.json', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'serve' ), + 'permission_callback' => array( __CLASS__, 'check_permission' ), + 'args' => array( + 'agent' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_title', + ), + ), + ) + ); + } + + public static function check_permission( WP_REST_Request $request ) { + /** + * Filters whether the current request may read an agent card. + * + * Defaults to `manage_options`, matching the bridge endpoint the card + * describes — the card exposes the agent's system prompt and tool + * inventory. Return `true` to enable public A2A discovery. + * + * @since 0.7.0 + * + * @param bool $allowed Default: current_user_can( 'manage_options' ). + * @param WP_REST_Request $request Current request. + */ + return (bool) apply_filters( 'openclawp_agent_card_permission', current_user_can( 'manage_options' ), $request ); + } + + public static function serve( WP_REST_Request $request ) { + $slug = (string) $request->get_param( 'agent' ); + + if ( ! function_exists( 'wp_get_agent' ) ) { + return new WP_Error( 'openclawp_agents_api_missing', __( 'Agents API is not loaded.', 'openclawp' ), array( 'status' => 500 ) ); + } + + $agent = wp_get_agent( $slug ); + if ( null === $agent ) { + return new WP_Error( + 'openclawp_agent_not_found', + sprintf( + /* translators: %s: agent slug. */ + __( 'No agent named "%s" is registered.', 'openclawp' ), + $slug + ), + array( 'status' => 404 ) + ); + } + + $rpc_url = rest_url( self::NAMESPACE . '/agenttic/' . $slug ); + $card_url = $rpc_url . '/.well-known/agent-card.json'; + + $card = self::build_card_data( self::agent_to_descriptor( $agent ), $card_url, $rpc_url ); + + return new WP_REST_Response( $card, 200 ); + } + + /** + * Flatten a WP_Agent into the plain descriptor the pure card builder + * consumes. Resolves each configured tool's label/description through the + * Abilities API and each subagent's label/description through the Agents + * registry so the card carries human-readable skills. + * + * @param WP_Agent $agent Registered agent. + * + * @return array{slug:string,label:string,description:string,tools:array,subagents:array} + */ + private static function agent_to_descriptor( WP_Agent $agent ): array { + $config = method_exists( $agent, 'get_default_config' ) ? $agent->get_default_config() : array(); + $tool_names = isset( $config['tools'] ) && is_array( $config['tools'] ) ? $config['tools'] : array(); + $subagents = method_exists( $agent, 'get_subagents' ) ? $agent->get_subagents() : array(); + + $tools = array(); + foreach ( $tool_names as $ability_name ) { + $ability_name = (string) $ability_name; + $label = $ability_name; + $description = ''; + if ( function_exists( 'wp_get_ability' ) ) { + $ability = wp_get_ability( $ability_name ); + if ( null !== $ability ) { + $label = method_exists( $ability, 'get_label' ) ? (string) $ability->get_label() : $ability_name; + $description = method_exists( $ability, 'get_description' ) ? (string) $ability->get_description() : ''; + } + } + $tools[] = array( + 'name' => $ability_name, + 'label' => $label, + 'description' => $description, + ); + } + + $subagent_descriptors = array(); + foreach ( $subagents as $subagent_slug ) { + $subagent_slug = (string) $subagent_slug; + $label = $subagent_slug; + $description = ''; + if ( function_exists( 'wp_get_agent' ) ) { + $subagent = wp_get_agent( $subagent_slug ); + if ( null !== $subagent ) { + $label = method_exists( $subagent, 'get_label' ) ? (string) $subagent->get_label() : $subagent_slug; + $description = method_exists( $subagent, 'get_description' ) ? (string) $subagent->get_description() : ''; + } + } + $subagent_descriptors[] = array( + 'slug' => $subagent_slug, + 'label' => $label, + 'description' => $description, + ); + } + + return array( + 'slug' => method_exists( $agent, 'get_slug' ) ? (string) $agent->get_slug() : '', + 'label' => method_exists( $agent, 'get_label' ) ? (string) $agent->get_label() : '', + 'description' => method_exists( $agent, 'get_description' ) ? (string) $agent->get_description() : '', + 'tools' => $tools, + 'subagents' => $subagent_descriptors, + ); + } + + /** + * Build the A2A Agent Card from a plain agent descriptor. Pure — no WP, no + * DB, no agent objects — so the card shape and skill derivation can be + * asserted without standing up WordPress. + * + * @param array $agent Agent descriptor (slug, label, description, tools[], subagents[]). + * @param string $card_url Absolute URL this card is served from. + * @param string $rpc_url Absolute URL of the JSON-RPC message endpoint. + * + * @return array + */ + public static function build_card_data( array $agent, string $card_url, string $rpc_url ): array { + $label = '' !== ( $agent['label'] ?? '' ) ? (string) $agent['label'] : (string) ( $agent['slug'] ?? '' ); + + return array( + 'protocolVersion' => self::PROTOCOL_VERSION, + 'name' => $label, + 'description' => (string) ( $agent['description'] ?? '' ), + 'url' => $rpc_url, + 'preferredTransport' => 'JSONRPC', + 'version' => defined( 'OPENCLAWP_VERSION' ) ? OPENCLAWP_VERSION : '0.0.0', + 'capabilities' => array( + // The bridge implements real SSE for message/stream; it does not + // yet implement push notifications or task history (see the v0 + // scope in OpenclaWP_Agenttic_Bridge). + 'streaming' => true, + 'pushNotifications' => false, + 'stateTransitionHistory' => false, + ), + 'defaultInputModes' => array( 'text/plain' ), + 'defaultOutputModes' => array( 'text/plain' ), + 'skills' => self::derive_skills( $agent ), + 'provider' => array( + 'organization' => 'openclaWP', + 'url' => function_exists( 'home_url' ) ? home_url() : '', + ), + 'documentationUrl' => $card_url, + ); + } + + /** + * Derive A2A skills from an agent's tools and subagents. Every card carries + * at least one skill (a generic "chat" skill) so a peer always sees + * something actionable, even for a tool-less conversational agent. + * + * @param array $agent Agent descriptor (slug, label, description, tools[], subagents[]). + * + * @return array}> + */ + private static function derive_skills( array $agent ): array { + $skills = array(); + + $tools = isset( $agent['tools'] ) && is_array( $agent['tools'] ) ? $agent['tools'] : array(); + foreach ( $tools as $tool ) { + $name = isset( $tool['name'] ) ? (string) $tool['name'] : ''; + if ( '' === $name ) { + continue; + } + $skills[] = array( + 'id' => $name, + 'name' => '' !== ( $tool['label'] ?? '' ) ? (string) $tool['label'] : $name, + 'description' => (string) ( $tool['description'] ?? '' ), + 'tags' => array( 'tool' ), + ); + } + + $subagents = isset( $agent['subagents'] ) && is_array( $agent['subagents'] ) ? $agent['subagents'] : array(); + foreach ( $subagents as $subagent ) { + $slug = isset( $subagent['slug'] ) ? (string) $subagent['slug'] : ''; + if ( '' === $slug ) { + continue; + } + $skills[] = array( + 'id' => 'delegate-to-' . $slug, + 'name' => '' !== ( $subagent['label'] ?? '' ) ? (string) $subagent['label'] : $slug, + 'description' => (string) ( $subagent['description'] ?? '' ), + 'tags' => array( 'subagent', 'delegation' ), + ); + } + + if ( empty( $skills ) ) { + $skills[] = array( + 'id' => 'chat', + 'name' => 'Chat', + 'description' => 'Converse with this agent. No specialised tools advertised.', + 'tags' => array( 'chat' ), + ); + } + + return $skills; + } +} diff --git a/includes/class-openclawp-agenttic-bridge.php b/includes/class-openclawp-agenttic-bridge.php index ce7e8e7..97e8311 100644 --- a/includes/class-openclawp-agenttic-bridge.php +++ b/includes/class-openclawp-agenttic-bridge.php @@ -169,6 +169,15 @@ public static function handle( WP_REST_Request $request ): WP_REST_Response { $session_id = isset( $params['sessionId'] ) && is_string( $params['sessionId'] ) ? $params['sessionId'] : null; $task_id = isset( $params['id'] ) && is_string( $params['id'] ) ? $params['id'] : self::generate_task_id(); + // When the request carries agents-api caller-chain headers, this is an + // agent-to-agent delegation. Parse them fail-closed (malformed headers + // are a hard error, matching the substrate's request-edge contract) and + // tag the turn so `agents/chat` records it as a peer-agent call (#180). + $client_context = self::peer_client_context( $request ); + if ( is_wp_error( $client_context ) ) { + return self::error_response( $rpc_id, self::INVALID_PARAMS, $client_context->get_error_message() ); + } + if ( ! function_exists( 'wp_get_ability' ) ) { return self::error_response( $rpc_id, self::INTERNAL_ERROR, 'Abilities API is not loaded.' ); } @@ -185,14 +194,17 @@ public static function handle( WP_REST_Request $request ): WP_REST_Response { $listeners_attached = self::attach_streaming_listeners( $rpc_id, $task_id ); } + $execute_args = array( + 'agent' => $agent_slug, + 'message' => $text, + 'session_id' => $session_id, + ); + if ( ! empty( $client_context ) ) { + $execute_args['client_context'] = $client_context; + } + try { - $result = $chat->execute( - array( - 'agent' => $agent_slug, - 'message' => $text, - 'session_id' => $session_id, - ) - ); + $result = $chat->execute( $execute_args ); } finally { if ( $listeners_attached ) { self::detach_streaming_listeners(); @@ -404,6 +416,44 @@ private static function error_response( $rpc_id, int $code, string $message, arr ); } + /** + * Build the `client_context` for an inbound A2A turn. + * + * Returns an empty array for a normal (non-peer) call so the chat ability + * sees no extra context. When agents-api caller-chain headers are present + * and describe a remote caller, returns a `peer-agent` client context + * carrying the caller agent slug and marking the turn as an explicit + * agent-to-agent delegation (#180). Malformed headers fail closed with a + * WP_Error, matching the substrate's request-edge contract (#81). + * + * @param WP_REST_Request $request Inbound request. + * + * @return array|WP_Error + */ + private static function peer_client_context( WP_REST_Request $request ) { + if ( ! class_exists( 'WP_Agent_Caller_Context' ) ) { + return array(); + } + + try { + $context = WP_Agent_Caller_Context::from_headers( $request ); + } catch ( \Throwable ) { + return new WP_Error( 'openclawp_invalid_caller_context', 'Invalid agent caller-context headers.' ); + } + + if ( ! $context->is_cross_site() ) { + return array(); + } + + return array( + 'source' => 'peer-agent', + 'caller_agent' => $context->caller_agent_id, + // The caller's own session id isn't carried in caller-chain headers. + 'caller_session_id' => null, + 'peer_agent_call' => true, + ); + } + private static function generate_task_id(): string { return 'task-' . wp_generate_password( 12, false, false ); } diff --git a/includes/class-openclawp-bootstrap.php b/includes/class-openclawp-bootstrap.php index d661f0b..7e67b22 100644 --- a/includes/class-openclawp-bootstrap.php +++ b/includes/class-openclawp-bootstrap.php @@ -71,6 +71,8 @@ public static function init(): void { OpenclaWP_Agency_Rest::register(); OpenclaWP_Decisions_Rest::register(); OpenclaWP_Agenttic_Bridge::register(); + OpenclaWP_Agent_Card::register(); + OpenclaWP_A2a_Client_Bridge::register(); OpenclaWP_Canonical_Chat_Handler::register(); OpenclaWP_Workflow_Bootstrap::register(); OpenclaWP_Routines_Rest::register(); diff --git a/includes/class-openclawp-message-adapter.php b/includes/class-openclawp-message-adapter.php index 96b5bcd..7643d1c 100644 --- a/includes/class-openclawp-message-adapter.php +++ b/includes/class-openclawp-message-adapter.php @@ -71,6 +71,16 @@ public static function last_assistant_text( array $messages ): string { if ( 'assistant' !== ( $message['role'] ?? '' ) ) { continue; } + // Skip internal-infrastructure envelopes from agents-api. Tool + // calls, tool results, deltas, errors etc. all share role + // 'assistant' but carry a typed payload that's NOT user-facing. + // Without this filter, an LLM turn that finishes on a tool_call + // without a subsequent text reply leaks "Calling " + // out to the WhatsApp recipient as if it were the bot's answer. + $type = (string) ( $message['type'] ?? 'text' ); + if ( '' !== $type && 'text' !== $type ) { + continue; + } $text = self::extract_text( $message['content'] ?? '' ); if ( '' !== $text ) { return $text; diff --git a/includes/class-openclawp-whatsapp.php b/includes/class-openclawp-whatsapp.php index b820744..a10d3d5 100644 --- a/includes/class-openclawp-whatsapp.php +++ b/includes/class-openclawp-whatsapp.php @@ -244,13 +244,45 @@ public static function extract_messages( array $payload ): array { } elseif ( 'interactive' === $type ) { $interactive = isset( $message['interactive'] ) && is_array( $message['interactive'] ) ? $message['interactive'] : array(); $itype = (string) ( $interactive['type'] ?? '' ); - $reply_id = 'button_reply' === $itype - ? (string) ( $interactive['button_reply']['id'] ?? '' ) - : ( 'list_reply' === $itype ? (string) ( $interactive['list_reply']['id'] ?? '' ) : '' ); + $reply_id = ''; + if ( 'button_reply' === $itype ) { + $reply_id = (string) ( $interactive['button_reply']['id'] ?? '' ); + } elseif ( 'list_reply' === $itype ) { + $reply_id = (string) ( $interactive['list_reply']['id'] ?? '' ); + } elseif ( 'nfm_reply' === $itype ) { + // WhatsApp Flow submission. The form payload arrives as + // JSON in response_json. We surface it as a virtual + // command `whatsapp_flow::` so the consumer + // can route by flow name and parse the payload. + $nfm = isset( $interactive['nfm_reply'] ) && is_array( $interactive['nfm_reply'] ) ? $interactive['nfm_reply'] : array(); + $response = (string) ( $nfm['response_json'] ?? '' ); + $flow_name = (string) ( $nfm['name'] ?? 'flow' ); + if ( '' !== $response ) { + $reply_id = 'whatsapp_flow:' . $flow_name . ':' . $response; + } + } if ( '' === $reply_id ) { continue; } $out[] = array( 'type' => 'text', 'phone' => $phone, 'text' => $reply_id, 'id' => $id, 'sender_name' => $name ); + } elseif ( 'reaction' === $type ) { + // Message reactions arrive as their own type. Surface as a + // virtual command `whatsapp_reaction::` so + // the consumer can map reactions to actions (e.g. 👍 on a + // match-detail bubble = accept the suggested score). + $reaction = isset( $message['reaction'] ) && is_array( $message['reaction'] ) ? $message['reaction'] : array(); + $emoji = (string) ( $reaction['emoji'] ?? '' ); + $target = (string) ( $reaction['message_id'] ?? '' ); + if ( '' === $emoji && '' === $target ) { + continue; + } + $out[] = array( + 'type' => 'text', + 'phone' => $phone, + 'text' => 'whatsapp_reaction:' . $emoji . ':' . $target, + 'id' => $id, + 'sender_name' => $name, + ); } elseif ( 'image' === $type ) { $img = isset( $message['image'] ) && is_array( $message['image'] ) ? $message['image'] : array(); if ( empty( $img['id'] ) ) { @@ -639,9 +671,33 @@ private static function build_interactive_payload( string $body_text, array $int 'body' => $body, 'action' => array( 'buttons' => $buttons ), ); - if ( ! empty( $interactive['header'] ) ) { - $payload['header'] = array( 'type' => 'text', 'text' => self::truncate( (string) $interactive['header'], 60 ) ); + self::apply_interactive_header( $payload, $interactive ); + if ( ! empty( $interactive['footer'] ) ) { + $payload['footer'] = array( 'text' => self::truncate( (string) $interactive['footer'], 60 ) ); } + return $payload; + } + + if ( 'flow' === $type ) { + // WhatsApp Flow message. Consumer passes a fully-shaped + // action.parameters block (flow_id, flow_token, flow_cta, + // flow_action, flow_action_payload) — we just package it. + $params = isset( $interactive['parameters'] ) && is_array( $interactive['parameters'] ) ? $interactive['parameters'] : array(); + if ( empty( $params['flow_id'] ) || empty( $params['flow_cta'] ) ) { + return null; + } + $payload = array( + 'type' => 'flow', + 'body' => $body, + 'action' => array( + 'name' => 'flow', + 'parameters' => array_merge( + array( 'flow_message_version' => '3' ), + $params + ), + ), + ); + self::apply_interactive_header( $payload, $interactive ); if ( ! empty( $interactive['footer'] ) ) { $payload['footer'] = array( 'text' => self::truncate( (string) $interactive['footer'], 60 ) ); } @@ -685,9 +741,7 @@ private static function build_interactive_payload( string $body_text, array $int 'sections' => $sections, ), ); - if ( ! empty( $interactive['header'] ) ) { - $payload['header'] = array( 'type' => 'text', 'text' => self::truncate( (string) $interactive['header'], 60 ) ); - } + self::apply_interactive_header( $payload, $interactive ); if ( ! empty( $interactive['footer'] ) ) { $payload['footer'] = array( 'text' => self::truncate( (string) $interactive['footer'], 60 ) ); } @@ -697,6 +751,41 @@ private static function build_interactive_payload( string $body_text, array $int return null; } + /** + * Apply a header to an interactive payload. Supports four header shapes: + * - $interactive['header'] = "string" → text header + * - $interactive['header'] = ['text' => '…'] → text header + * - $interactive['header'] = ['image' => 'url'] → image header (link) + * - $interactive['header'] = ['image_id' => '…'] → image header (media id) + * + * Header text is capped at 60 chars per the WhatsApp Cloud API limit. + */ + private static function apply_interactive_header( array &$payload, array $interactive ): void { + $header = $interactive['header'] ?? null; + if ( empty( $header ) ) { + return; + } + if ( is_string( $header ) ) { + $payload['header'] = array( 'type' => 'text', 'text' => self::truncate( $header, 60 ) ); + return; + } + if ( ! is_array( $header ) ) { + return; + } + if ( ! empty( $header['text'] ) ) { + $payload['header'] = array( 'type' => 'text', 'text' => self::truncate( (string) $header['text'], 60 ) ); + return; + } + if ( ! empty( $header['image'] ) ) { + $payload['header'] = array( 'type' => 'image', 'image' => array( 'link' => (string) $header['image'] ) ); + return; + } + if ( ! empty( $header['image_id'] ) ) { + $payload['header'] = array( 'type' => 'image', 'image' => array( 'id' => (string) $header['image_id'] ) ); + return; + } + } + private static function truncate( string $text, int $max ): string { $text = trim( $text ); if ( function_exists( 'mb_strimwidth' ) ) { diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f74a891..6623276 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -107,11 +107,42 @@ function wp_remote_post( string $url, array $args = array() ) { ); } } +if ( ! function_exists( 'wp_remote_get' ) ) { + function wp_remote_get( string $url, array $args = array() ) { + $GLOBALS['openclawp_test_http_get_capture'][] = array( + 'url' => $url, + 'args' => $args, + ); + if ( isset( $GLOBALS['openclawp_test_http_get_response'] ) ) { + return $GLOBALS['openclawp_test_http_get_response']; + } + return array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => '', + ); + } +} if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { function wp_remote_retrieve_response_code( $response ): int { return (int) ( $response['response']['code'] ?? 0 ); } } +if ( ! function_exists( 'home_url' ) ) { + function home_url( string $path = '' ): string { + return 'https://openclawp.test' . $path; + } +} +if ( ! function_exists( 'wp_generate_password' ) ) { + function wp_generate_password( int $length = 12, bool $special_chars = true, bool $extra_special_chars = false ): string { + unset( $special_chars, $extra_special_chars ); + return substr( str_repeat( 'abcdef0123456789', 4 ), 0, max( 1, $length ) ); + } +} +if ( ! function_exists( 'wp_generate_uuid4' ) ) { + function wp_generate_uuid4(): string { + return '00000000-0000-4000-8000-000000000000'; + } +} if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { function wp_remote_retrieve_body( $response ): string { return (string) ( $response['body'] ?? '' ); @@ -139,6 +170,11 @@ function sanitize_textarea_field( string $text ): string { return trim( preg_replace( '/[\r\t ]+/', ' ', strip_tags( $text ) ) ?? '' ); } } +if ( ! function_exists( 'wp_parse_url' ) ) { + function wp_parse_url( string $url, int $component = -1 ) { + return parse_url( $url, $component ); + } +} if ( ! function_exists( 'wp_http_validate_url' ) ) { function wp_http_validate_url( string $url ) { return filter_var( $url, FILTER_VALIDATE_URL ) ? $url : false; diff --git a/tests/smoke.php b/tests/smoke.php index 0383486..f0e7d73 100644 --- a/tests/smoke.php +++ b/tests/smoke.php @@ -1199,6 +1199,89 @@ class_exists( 'OpenclaWP_Mcp_Adapter' ) ); } +// --------------------------------------------------------------------------- +// A2A agent mesh: agent card (discovery) + A2A peer client bridge. +// --------------------------------------------------------------------------- +if ( class_exists( 'OpenclaWP_Agent_Card' ) && function_exists( 'wp_register_agent' ) ) { + OpenclaWP_Smoke::register_agent( + 'openclawp-smoke-card', + array( + 'label' => 'openclaWP Smoke Card', + 'description' => 'Agent used by the smoke test to exercise the A2A card.', + 'owner_resolver' => static fn(): int => get_current_user_id(), + 'default_config' => array( + 'provider' => 'auto', + 'model' => 'claude-haiku-4-5', + 'tools' => array( 'openclawp/get-recent-posts' ), + ), + ) + ); + + $card_route = rest_url( 'openclawp/v1/agenttic/openclawp-smoke-card/.well-known/agent-card.json' ); + + // Gated by default: an unauthenticated fetch must be refused (the card + // exposes the system prompt + tool inventory, so it mirrors the bridge's + // manage_options gate). + $card_gated = wp_remote_get( $card_route, array( 'timeout' => 15 ) ); + OpenclaWP_Smoke::check( + 'agent card is gated for unauthenticated callers', + ! is_wp_error( $card_gated ) && in_array( (int) wp_remote_retrieve_response_code( $card_gated ), array( 401, 403 ), true ) + ); + + // Opt into public discovery via the filter, then fetch + assert the shape. + $open_card = static fn (): bool => true; + add_filter( 'openclawp_agent_card_permission', $open_card ); + $card_resp = wp_remote_get( $card_route, array( 'timeout' => 15 ) ); + remove_filter( 'openclawp_agent_card_permission', $open_card ); + + $card_code = is_wp_error( $card_resp ) ? 0 : (int) wp_remote_retrieve_response_code( $card_resp ); + $card_body = is_wp_error( $card_resp ) ? array() : (array) json_decode( (string) wp_remote_retrieve_body( $card_resp ), true ); + + OpenclaWP_Smoke::check( + 'agent card served with 200 when discovery is opened via filter', + 200 === $card_code + ); + OpenclaWP_Smoke::check( + 'agent card advertises name + JSONRPC transport + streaming', + 'openclaWP Smoke Card' === ( $card_body['name'] ?? '' ) + && 'JSONRPC' === ( $card_body['preferredTransport'] ?? '' ) + && true === ( $card_body['capabilities']['streaming'] ?? false ) + ); + OpenclaWP_Smoke::check( + 'agent card derives a skill from the configured tool', + in_array( 'openclawp/get-recent-posts', array_column( (array) ( $card_body['skills'] ?? array() ), 'id' ), true ) + ); + + add_filter( 'openclawp_agent_card_permission', $open_card ); + $card_404 = wp_remote_get( + rest_url( 'openclawp/v1/agenttic/no-such-agent-xyz/.well-known/agent-card.json' ), + array( 'timeout' => 15 ) + ); + remove_filter( 'openclawp_agent_card_permission', $open_card ); + OpenclaWP_Smoke::check( + 'agent card returns 404 for an unregistered agent', + ! is_wp_error( $card_404 ) && 404 === (int) wp_remote_retrieve_response_code( $card_404 ) + ); +} + +if ( class_exists( 'OpenclaWP_A2a_Client_Bridge' ) && function_exists( 'wp_register_ability' ) ) { + $peer_filter = static function ( $peers ) { + $peers['smoke-peer'] = array( + 'label' => 'Smoke Peer', + 'endpoint' => 'https://peer.invalid/wp-json/openclawp/v1/agenttic/openclawp-example', + ); + return $peers; + }; + add_filter( 'openclawp_a2a_peers', $peer_filter ); + OpenclaWP_A2a_Client_Bridge::register_bridged_abilities(); + remove_filter( 'openclawp_a2a_peers', $peer_filter ); + + OpenclaWP_Smoke::check( + 'A2A peer registers as an a2a/ ability', + function_exists( 'wp_has_ability' ) && wp_has_ability( 'a2a/smoke-peer' ) + ); +} + $failed = OpenclaWP_Smoke::summarize(); if ( $failed > 0 ) { exit( 1 ); diff --git a/tests/unit/A2aClientBridgeTest.php b/tests/unit/A2aClientBridgeTest.php new file mode 100644 index 0000000..10a3902 --- /dev/null +++ b/tests/unit/A2aClientBridgeTest.php @@ -0,0 +1,103 @@ +` prefix, that peers without a slug or usable endpoint are + * dropped, and that headers/label/local_agent are carried through. The + * plan_peer_list() helper is pure-PHP — no DB, no WP, no HTTP — mirroring the + * MCP client bridge's plan_ability_list(). The full path that calls + * wp_register_ability() is covered by tests/smoke.php. + * + * @package OpenclaWP\Tests + */ + +declare( strict_types=1 ); + +namespace OpenclaWP\Tests\Unit; + +use OpenclaWP_A2a_Client_Bridge; +use PHPUnit\Framework\TestCase; + +/** + * @covers OpenclaWP_A2a_Client_Bridge + */ +final class A2aClientBridgeTest extends TestCase { + + public function test_registers_under_a2a_prefix_with_peer_slug(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( + 'site-b' => array( + 'label' => 'Client Site B', + 'endpoint' => 'https://site-b.test/wp-json/openclawp/v1/agenttic/openclawp-site-introspection', + ), + ) + ); + + $this->assertCount( 1, $plan ); + $this->assertSame( 'a2a/site-b', $plan[0]['ability_name'] ); + $this->assertStringStartsWith( OpenclaWP_A2a_Client_Bridge::ABILITY_PREFIX, $plan[0]['ability_name'] ); + $this->assertSame( 'site-b', $plan[0]['slug'] ); + $this->assertSame( 'Client Site B', $plan[0]['label'] ); + $this->assertSame( 'https://site-b.test/wp-json/openclawp/v1/agenttic/openclawp-site-introspection', $plan[0]['endpoint'] ); + } + + public function test_peer_without_endpoint_is_dropped(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( + 'good' => array( 'endpoint' => 'https://good.test/wp-json/openclawp/v1/agenttic/x' ), + 'bad' => array( 'label' => 'No endpoint here' ), + ) + ); + + $slugs = array_column( $plan, 'slug' ); + $this->assertCount( 1, $plan ); + $this->assertContains( 'good', $slugs ); + $this->assertNotContains( 'bad', $slugs ); + } + + public function test_label_falls_back_to_slug(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( 'peer-x' => array( 'endpoint' => 'https://x.test/wp-json/openclawp/v1/agenttic/x' ) ) + ); + + $this->assertSame( 'peer-x', $plan[0]['label'] ); + } + + public function test_slug_is_sanitized(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( 'Client Site!' => array( 'endpoint' => 'https://x.test/wp-json/openclawp/v1/agenttic/x' ) ) + ); + + $this->assertCount( 1, $plan ); + $this->assertSame( 'client-site', $plan[0]['slug'] ); + $this->assertSame( 'a2a/client-site', $plan[0]['ability_name'] ); + } + + public function test_headers_and_local_agent_carry_through(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( + 'site-b' => array( + 'endpoint' => 'https://site-b.test/wp-json/openclawp/v1/agenttic/x', + 'headers' => array( 'Authorization' => 'Bearer abc123' ), + 'local_agent' => 'openclawp-coordinator', + ), + ) + ); + + $this->assertSame( array( 'Authorization' => 'Bearer abc123' ), $plan[0]['headers'] ); + $this->assertSame( 'openclawp-coordinator', $plan[0]['local_agent'] ); + } + + public function test_non_array_peer_config_is_skipped(): void { + $plan = OpenclaWP_A2a_Client_Bridge::plan_peer_list( + array( + 'ok' => array( 'endpoint' => 'https://ok.test/wp-json/openclawp/v1/agenttic/x' ), + 'garbage' => 'not-an-array', + ) + ); + + $this->assertCount( 1, $plan ); + $this->assertSame( 'ok', $plan[0]['slug'] ); + } +} diff --git a/tests/unit/A2aClientTransportTest.php b/tests/unit/A2aClientTransportTest.php new file mode 100644 index 0000000..13142c0 --- /dev/null +++ b/tests/unit/A2aClientTransportTest.php @@ -0,0 +1,234 @@ + '2.0', + 'id' => 'req-1', + 'result' => array( + 'id' => 'task-1', + 'sessionId' => 'sess-99', + 'status' => array( + 'state' => 'completed', + 'message' => array( + 'role' => 'agent', + 'parts' => array( + array( 'type' => 'text', 'text' => 'Hello from the peer.' ), + ), + ), + ), + ), + ); + + $parsed = OpenclaWP_A2a_Client_Transport::parse_task_response( $envelope ); + + $this->assertIsArray( $parsed ); + $this->assertSame( 'Hello from the peer.', $parsed['reply'] ); + $this->assertSame( 'sess-99', $parsed['session_id'] ); + $this->assertSame( 'task-1', $parsed['task']['id'] ); + } + + public function test_parse_task_response_surfaces_rpc_error(): void { + $parsed = OpenclaWP_A2a_Client_Transport::parse_task_response( + array( + 'jsonrpc' => '2.0', + 'id' => 'req-1', + 'error' => array( 'code' => -32603, 'message' => 'peer blew up' ), + ) + ); + + $this->assertInstanceOf( WP_Error::class, $parsed ); + $this->assertStringContainsString( 'peer blew up', $parsed->get_error_message() ); + } + + public function test_parse_task_response_errors_on_missing_result(): void { + $parsed = OpenclaWP_A2a_Client_Transport::parse_task_response( array( 'jsonrpc' => '2.0', 'id' => 'x' ) ); + $this->assertInstanceOf( WP_Error::class, $parsed ); + } + + // ---- caller_headers ------------------------------------------------ + + public function test_caller_headers_describe_a_chained_remote_call(): void { + $headers = OpenclaWP_A2a_Client_Transport::caller_headers( + array( 'agent' => 'openclawp-coordinator', 'user_id' => 7 ) + ); + + // A call originating here is one hop above top-of-chain. + $this->assertSame( 'openclawp-coordinator', $headers['X-Agents-Api-Caller-Agent'] ); + $this->assertSame( '7', $headers['X-Agents-Api-Caller-User'] ); + $this->assertSame( '1', $headers['X-Agents-Api-Chain-Depth'] ); + // caller_host must be an absolute URL, never "self", for chained calls. + $this->assertNotSame( 'self', $headers['X-Agents-Api-Caller-Host'] ); + $this->assertStringStartsWith( 'http', $headers['X-Agents-Api-Caller-Host'] ); + $this->assertNotSame( '', $headers['X-Agents-Api-Chain-Root'] ); + } + + public function test_caller_headers_increment_inbound_depth(): void { + $headers = OpenclaWP_A2a_Client_Transport::caller_headers( + array( 'agent' => 'a', 'chain_depth' => 3, 'chain_root' => 'root-abc' ) + ); + + $this->assertSame( '4', $headers['X-Agents-Api-Chain-Depth'] ); + $this->assertSame( 'root-abc', $headers['X-Agents-Api-Chain-Root'] ); + } + + public function test_caller_headers_empty_without_agent(): void { + $this->assertSame( array(), OpenclaWP_A2a_Client_Transport::caller_headers( array() ) ); + } + + public function test_caller_headers_round_trip_through_agents_api_when_available(): void { + if ( ! class_exists( 'WP_Agent_Caller_Context' ) ) { + $this->markTestSkipped( 'agents-api WP_Agent_Caller_Context not autoloaded in this run.' ); + } + + $headers = OpenclaWP_A2a_Client_Transport::caller_headers( + array( 'agent' => 'openclawp-coordinator', 'user_id' => 7 ) + ); + + // The peer parses these with from_headers(); a chained context must + // survive the round-trip without throwing. + $context = \WP_Agent_Caller_Context::from_headers( $headers ); + $this->assertSame( 'openclawp-coordinator', $context->caller_agent_id ); + $this->assertSame( 1, $context->chain_depth ); + $this->assertTrue( $context->is_cross_site() ); + } + + // ---- send_message (round-trip via stub) ---------------------------- + + public function test_send_message_posts_jsonrpc_and_returns_reply(): void { + $GLOBALS['openclawp_test_http_response'] = array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => wp_json_encode( + array( + 'jsonrpc' => '2.0', + 'id' => 'req-1', + 'result' => array( + 'id' => 'task-1', + 'sessionId' => 'sess-1', + 'status' => array( + 'message' => array( + 'parts' => array( array( 'type' => 'text', 'text' => 'pong' ) ), + ), + ), + ), + ) + ), + ); + + $result = OpenclaWP_A2a_Client_Transport::send_message( + 'https://peer.test/wp-json/openclawp/v1/agenttic/openclawp-loop-demo', + 'ping', + null, + array( 'agent' => 'openclawp-coordinator' ), + array( 'Authorization' => 'Bearer xyz' ) + ); + + $this->assertIsArray( $result ); + $this->assertSame( 'pong', $result['reply'] ); + + // Assert the outbound request shape. + $captured = $GLOBALS['openclawp_test_http_capture'][0] ?? null; + $this->assertNotNull( $captured ); + $this->assertSame( 'https://peer.test/wp-json/openclawp/v1/agenttic/openclawp-loop-demo', $captured['url'] ); + + $sent = json_decode( $captured['args']['body'], true ); + $this->assertSame( '2.0', $sent['jsonrpc'] ); + $this->assertSame( 'message/send', $sent['method'] ); + $this->assertSame( 'ping', $sent['params']['message']['parts'][0]['text'] ); + + // Caller-context + auth headers ride along. + $this->assertSame( 'openclawp-coordinator', $captured['args']['headers']['X-Agents-Api-Caller-Agent'] ); + $this->assertSame( 'Bearer xyz', $captured['args']['headers']['Authorization'] ); + } + + public function test_send_message_includes_session_id_when_continuing(): void { + $GLOBALS['openclawp_test_http_response'] = array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => wp_json_encode( + array( 'jsonrpc' => '2.0', 'id' => 'r', 'result' => array( 'sessionId' => 'sess-1', 'status' => array( 'message' => array( 'parts' => array( array( 'type' => 'text', 'text' => 'ok' ) ) ) ) ) ) + ), + ); + + OpenclaWP_A2a_Client_Transport::send_message( + 'https://peer.test/x', + 'again', + 'sess-1', + array( 'agent' => 'a' ) + ); + + $sent = json_decode( $GLOBALS['openclawp_test_http_capture'][0]['args']['body'], true ); + $this->assertSame( 'sess-1', $sent['params']['sessionId'] ); + } + + public function test_send_message_errors_on_http_failure_status(): void { + $GLOBALS['openclawp_test_http_response'] = array( + 'response' => array( 'code' => 500, 'message' => 'Server Error' ), + 'body' => 'boom', + ); + + $result = OpenclaWP_A2a_Client_Transport::send_message( 'https://peer.test/x', 'ping', null, array( 'agent' => 'a' ) ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'a2a_client_http_status', $result->get_error_code() ); + } + + public function test_send_message_requires_endpoint_and_text(): void { + $this->assertInstanceOf( WP_Error::class, OpenclaWP_A2a_Client_Transport::send_message( '', 'hi', null ) ); + $this->assertInstanceOf( WP_Error::class, OpenclaWP_A2a_Client_Transport::send_message( 'https://peer.test/x', ' ', null ) ); + } + + // ---- fetch_card ---------------------------------------------------- + + public function test_fetch_card_returns_decoded_json(): void { + $GLOBALS['openclawp_test_http_get_response'] = array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => wp_json_encode( array( 'name' => 'Peer Agent', 'skills' => array() ) ), + ); + + $card = OpenclaWP_A2a_Client_Transport::fetch_card( 'https://peer.test/wp-json/openclawp/v1/agenttic/x/.well-known/agent-card.json' ); + $this->assertIsArray( $card ); + $this->assertSame( 'Peer Agent', $card['name'] ); + } + + public function test_fetch_card_errors_on_non_json(): void { + $GLOBALS['openclawp_test_http_get_response'] = array( + 'response' => array( 'code' => 200, 'message' => 'OK' ), + 'body' => 'not json', + ); + + $card = OpenclaWP_A2a_Client_Transport::fetch_card( 'https://peer.test/card.json' ); + $this->assertInstanceOf( WP_Error::class, $card ); + } +} diff --git a/tests/unit/AgentCardTest.php b/tests/unit/AgentCardTest.php new file mode 100644 index 0000000..faa3e56 --- /dev/null +++ b/tests/unit/AgentCardTest.php @@ -0,0 +1,133 @@ + 'openclawp-coordinator', + 'label' => 'openclaWP Coordinator', + 'description' => 'Routes work to subagents.', + 'tools' => array(), + 'subagents' => array(), + ), + self::CARD_URL, + self::RPC_URL + ); + + $this->assertSame( 'openclaWP Coordinator', $card['name'] ); + $this->assertSame( 'Routes work to subagents.', $card['description'] ); + $this->assertSame( self::RPC_URL, $card['url'] ); + $this->assertSame( 'JSONRPC', $card['preferredTransport'] ); + $this->assertArrayHasKey( 'protocolVersion', $card ); + $this->assertContains( 'text/plain', $card['defaultInputModes'] ); + $this->assertContains( 'text/plain', $card['defaultOutputModes'] ); + } + + public function test_streaming_capability_is_advertised(): void { + $card = OpenclaWP_Agent_Card::build_card_data( + array( 'slug' => 'a', 'label' => 'A', 'description' => '', 'tools' => array(), 'subagents' => array() ), + self::CARD_URL, + self::RPC_URL + ); + + // The bridge implements real SSE for message/stream, but no push or history. + $this->assertTrue( $card['capabilities']['streaming'] ); + $this->assertFalse( $card['capabilities']['pushNotifications'] ); + $this->assertFalse( $card['capabilities']['stateTransitionHistory'] ); + } + + public function test_tools_become_skills(): void { + $card = OpenclaWP_Agent_Card::build_card_data( + array( + 'slug' => 'site-introspection', + 'label' => 'Site Introspection', + 'description' => '', + 'tools' => array( + array( 'name' => 'openclawp/get-recent-posts', 'label' => 'Recent posts', 'description' => 'List recent posts.' ), + array( 'name' => 'openclawp/count-comments', 'label' => 'Comment counts', 'description' => '' ), + ), + 'subagents' => array(), + ), + self::CARD_URL, + self::RPC_URL + ); + + $ids = array_column( $card['skills'], 'id' ); + $this->assertContains( 'openclawp/get-recent-posts', $ids ); + $this->assertContains( 'openclawp/count-comments', $ids ); + + $first = $card['skills'][0]; + $this->assertSame( 'Recent posts', $first['name'] ); + $this->assertSame( 'List recent posts.', $first['description'] ); + $this->assertContains( 'tool', $first['tags'] ); + } + + public function test_subagents_become_delegation_skills(): void { + $card = OpenclaWP_Agent_Card::build_card_data( + array( + 'slug' => 'coordinator', + 'label' => 'Coordinator', + 'description' => '', + 'tools' => array(), + 'subagents' => array( + array( 'slug' => 'openclawp-loop-demo', 'label' => 'Loop Demo', 'description' => 'Tells the time.' ), + ), + ), + self::CARD_URL, + self::RPC_URL + ); + + $ids = array_column( $card['skills'], 'id' ); + $this->assertContains( 'delegate-to-openclawp-loop-demo', $ids ); + + $skill = $card['skills'][0]; + $this->assertContains( 'subagent', $skill['tags'] ); + $this->assertContains( 'delegation', $skill['tags'] ); + } + + public function test_toolless_agent_gets_generic_chat_skill(): void { + $card = OpenclaWP_Agent_Card::build_card_data( + array( 'slug' => 'plain', 'label' => 'Plain', 'description' => '', 'tools' => array(), 'subagents' => array() ), + self::CARD_URL, + self::RPC_URL + ); + + $this->assertCount( 1, $card['skills'] ); + $this->assertSame( 'chat', $card['skills'][0]['id'] ); + } + + public function test_name_falls_back_to_slug_when_label_empty(): void { + $card = OpenclaWP_Agent_Card::build_card_data( + array( 'slug' => 'no-label-agent', 'label' => '', 'description' => '', 'tools' => array(), 'subagents' => array() ), + self::CARD_URL, + self::RPC_URL + ); + + $this->assertSame( 'no-label-agent', $card['name'] ); + } +}