From ad98d41d619923ffdae709e57deaccfb0a7022f0 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Wed, 18 Mar 2026 05:49:08 +0000 Subject: [PATCH] feat(a2a): implement _cleveragents/ extension method routing Add spec-aligned _cleveragents/ prefixed extension method routing to the A2A local facade per ADR-047. The facade now supports 42 total operations: 31 new extension methods across 6 families plus 11 legacy proprietary names retained for backward compatibility. Extension method families implemented: - _cleveragents/plan/* (13 methods): use, execute, apply, cancel, status, tree, explain, correct, diff, artifacts, prompt, rollback, list. Plan operations delegate to PlanLifecycleService when wired; new operations (cancel, tree, explain, correct, artifacts, prompt, rollback, list) have stub handlers returning safe defaults. - _cleveragents/registry/* (6 methods): tool/list, resource/list, actor/list, skill/list, action/list, project/list. Tool and resource list delegate to existing services; entity lists are stubs. - _cleveragents/context/* (4 methods): show (delegates to existing handler), inspect, simulate, set (stubs). - _cleveragents/health/* (2 methods): check, diagnostics/run. - _cleveragents/sync/* (3 methods): pull, push, status (stubs). - _cleveragents/namespace/* (3 methods): list, show, members (stubs). Legacy proprietary names (session.create, plan.create, etc.) continue to work via the same handler map, marked deprecated. Updated existing test assertions for the expanded operation count (11 -> 42). ISSUES CLOSED: #876 --- features/a2a_extension_methods.feature | 66 ++++++ features/consolidated_misc.feature | 4 +- features/steps/a2a_extension_methods_steps.py | 109 ++++++++++ robot/helper_a2a_facade.py | 2 +- src/cleveragents/a2a/facade.py | 195 +++++++++++++++++- 5 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 features/a2a_extension_methods.feature create mode 100644 features/steps/a2a_extension_methods_steps.py diff --git a/features/a2a_extension_methods.feature b/features/a2a_extension_methods.feature new file mode 100644 index 00000000..8192e577 --- /dev/null +++ b/features/a2a_extension_methods.feature @@ -0,0 +1,66 @@ +@mock_only +Feature: A2A extension method routing via _cleveragents/ prefix + As a CleverAgents developer + I want the A2A local facade to support _cleveragents/ extension methods + So that operations use spec-aligned method names per ADR-047 + + Scenario: Facade supports all _cleveragents/ extension operations + Given a facade instance for extension method testing + Then the facade should support _cleveragents/plan/ methods + And the facade should support _cleveragents/registry/ methods + And the facade should support _cleveragents/context/ methods + And the facade should support _cleveragents/health/ methods + And the facade should support _cleveragents/sync/ methods + And the facade should support _cleveragents/namespace/ methods + + Scenario: Plan use via _cleveragents/plan/use + Given a facade instance for extension method testing + When I dispatch "_cleveragents/plan/use" with params {"action_name": "test"} + Then the response should be ok + And the response data should contain key "plan_id" + + Scenario: Plan execute via _cleveragents/plan/execute + Given a facade instance for extension method testing + When I dispatch "_cleveragents/plan/execute" with params {"plan_id": "test-id"} + Then the response should be ok + + Scenario: Plan cancel via _cleveragents/plan/cancel + Given a facade instance for extension method testing + When I dispatch "_cleveragents/plan/cancel" with params {"plan_id": "test-id"} + Then the response should be ok + + Scenario: Plan list via _cleveragents/plan/list + Given a facade instance for extension method testing + When I dispatch "_cleveragents/plan/list" with params {} + Then the response should be ok + And the response data should contain key "plans" + + Scenario: Health check via _cleveragents/health/check + Given a facade instance for extension method testing + When I dispatch "_cleveragents/health/check" with params {} + Then the response should be ok + And the response data should contain key "status" + + Scenario: Sync stub returns not_implemented + Given a facade instance for extension method testing + When I dispatch "_cleveragents/sync/pull" with params {} + Then the response should be ok + And the response data should have "stub" set to true + + Scenario: Namespace stub returns not_implemented + Given a facade instance for extension method testing + When I dispatch "_cleveragents/namespace/list" with params {} + Then the response should be ok + And the response data should have "stub" set to true + + Scenario: Legacy operation names still work + Given a facade instance for extension method testing + When I dispatch "plan.create" with params {"action_name": "test"} + Then the response should be ok + And the response data should contain key "plan_id" + + Scenario: Registry tool list via extension method + Given a facade instance for extension method testing + When I dispatch "_cleveragents/registry/tool/list" with params {} + Then the response should be ok + And the response data should contain key "tools" diff --git a/features/consolidated_misc.feature b/features/consolidated_misc.feature index a3112063..23da76be 100644 --- a/features/consolidated_misc.feature +++ b/features/consolidated_misc.feature @@ -140,7 +140,7 @@ Feature: Consolidated Misc And the operations list should contain "registry.list_tools" And the operations list should contain "context.get" And the operations list should contain "event.subscribe" - And the operations list should have 11 items + And the operations list should have 42 items # ----------------------------------------------------------------------- # A2aHttpTransport — all stubs raise A2aNotAvailableError @@ -637,7 +637,7 @@ Feature: Consolidated Misc Then the m6 smoke operations should include "session.create" And the m6 smoke operations should include "plan.execute" And the m6 smoke operations should include "event.subscribe" - And the m6 smoke operations count should be 11 + And the m6 smoke operations count should be 42 # --- A2A event queue --- diff --git a/features/steps/a2a_extension_methods_steps.py b/features/steps/a2a_extension_methods_steps.py new file mode 100644 index 00000000..d20d3c79 --- /dev/null +++ b/features/steps/a2a_extension_methods_steps.py @@ -0,0 +1,109 @@ +"""Step definitions for a2a_extension_methods.feature. + +Tests the _cleveragents/ extension method routing in the A2A local +facade, verifying spec-aligned operation names are supported and +produce valid responses. +""" + +from __future__ import annotations + +import json +from typing import Any + +from behave import given, then, when + +from cleveragents.a2a.facade import A2aLocalFacade +from cleveragents.a2a.models import A2aRequest + + +@given("a facade instance for extension method testing") +def step_create_extension_facade(context: Any) -> None: + """Create a facade with no services (all handlers return stubs).""" + context.facade = A2aLocalFacade() + + +@when('I dispatch "{operation}" with params {params_json}') +def step_dispatch_operation(context: Any, operation: str, params_json: str) -> None: + params = json.loads(params_json) + request = A2aRequest(operation=operation, params=params) + context.response = context.facade.dispatch(request) + + +@then("the response should be ok") +def step_response_ok(context: Any) -> None: + assert context.response.status == "ok", ( + f"Expected 'ok', got '{context.response.status}'" + ) + + +@then('the response data should contain key "{key}"') +def step_response_has_key(context: Any, key: str) -> None: + assert key in context.response.data, ( + f"Expected key '{key}' in response data, got: {list(context.response.data.keys())}" + ) + + +@then('the response data should have "{key}" set to true') +def step_response_stub_true(context: Any, key: str) -> None: + assert context.response.data.get(key) is True, ( + f"Expected data['{key}'] to be True, got: {context.response.data.get(key)}" + ) + + +@then("the facade should support _cleveragents/plan/ methods") +def step_check_plan_methods(context: Any) -> None: + ops = context.facade.list_operations() + plan_ops = [o for o in ops if o.startswith("_cleveragents/plan/")] + assert len(plan_ops) >= 13, ( + f"Expected >=13 plan extension methods, got {len(plan_ops)}: {plan_ops}" + ) + + +@then("the facade should support _cleveragents/registry/ methods") +def step_check_registry_methods(context: Any) -> None: + ops = context.facade.list_operations() + reg_ops = [o for o in ops if o.startswith("_cleveragents/registry/")] + assert len(reg_ops) >= 6, ( + f"Expected >=6 registry extension methods, got {len(reg_ops)}: {reg_ops}" + ) + + +@then("the facade should support _cleveragents/context/ methods") +def step_check_context_methods(context: Any) -> None: + ops = context.facade.list_operations() + ctx_ops = [o for o in ops if o.startswith("_cleveragents/context/")] + assert len(ctx_ops) >= 4, ( + f"Expected >=4 context extension methods, got {len(ctx_ops)}: {ctx_ops}" + ) + + +@then("the facade should support _cleveragents/health/ methods") +def step_check_health_methods(context: Any) -> None: + ops = context.facade.list_operations() + health_ops = [ + o + for o in ops + if o.startswith("_cleveragents/health/") + or o.startswith("_cleveragents/diagnostics/") + ] + assert len(health_ops) >= 2, ( + f"Expected >=2 health/diagnostics methods, got {len(health_ops)}: {health_ops}" + ) + + +@then("the facade should support _cleveragents/sync/ methods") +def step_check_sync_methods(context: Any) -> None: + ops = context.facade.list_operations() + sync_ops = [o for o in ops if o.startswith("_cleveragents/sync/")] + assert len(sync_ops) >= 3, ( + f"Expected >=3 sync extension methods, got {len(sync_ops)}: {sync_ops}" + ) + + +@then("the facade should support _cleveragents/namespace/ methods") +def step_check_namespace_methods(context: Any) -> None: + ops = context.facade.list_operations() + ns_ops = [o for o in ops if o.startswith("_cleveragents/namespace/")] + assert len(ns_ops) >= 3, ( + f"Expected >=3 namespace extension methods, got {len(ns_ops)}: {ns_ops}" + ) diff --git a/robot/helper_a2a_facade.py b/robot/helper_a2a_facade.py index 254fde81..aa221d20 100644 --- a/robot/helper_a2a_facade.py +++ b/robot/helper_a2a_facade.py @@ -93,7 +93,7 @@ def list_operations() -> None: facade = A2aLocalFacade() ops = facade.list_operations() expected = {"session.create", "plan.create", "plan.execute", "context.get"} - if expected.issubset(set(ops)) and len(ops) == 11: + if expected.issubset(set(ops)) and len(ops) == 42: print("a2a-list-operations-ok") else: print(f"FAIL: ops={ops}", file=sys.stderr) diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index 27cb2988..0243f33e 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -54,7 +54,49 @@ logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) # Supported operations # --------------------------------------------------------------------------- -_SUPPORTED_OPERATIONS: list[str] = [ +# Spec-aligned _cleveragents/ extension method names (ADR-047). +_EXTENSION_OPERATIONS: list[str] = [ + # Plan lifecycle + "_cleveragents/plan/use", + "_cleveragents/plan/execute", + "_cleveragents/plan/apply", + "_cleveragents/plan/cancel", + "_cleveragents/plan/status", + "_cleveragents/plan/tree", + "_cleveragents/plan/explain", + "_cleveragents/plan/correct", + "_cleveragents/plan/diff", + "_cleveragents/plan/artifacts", + "_cleveragents/plan/prompt", + "_cleveragents/plan/rollback", + "_cleveragents/plan/list", + # Registry + "_cleveragents/registry/tool/list", + "_cleveragents/registry/resource/list", + "_cleveragents/registry/actor/list", + "_cleveragents/registry/skill/list", + "_cleveragents/registry/action/list", + "_cleveragents/registry/project/list", + # Context + "_cleveragents/context/show", + "_cleveragents/context/inspect", + "_cleveragents/context/simulate", + "_cleveragents/context/set", + # Health / Diagnostics + "_cleveragents/health/check", + "_cleveragents/diagnostics/run", + # Sync (stub) + "_cleveragents/sync/pull", + "_cleveragents/sync/push", + "_cleveragents/sync/status", + # Namespace (stub) + "_cleveragents/namespace/list", + "_cleveragents/namespace/show", + "_cleveragents/namespace/members", +] + +# Legacy proprietary operation names (deprecated, kept for backward compat). +_LEGACY_OPERATIONS: list[str] = [ "session.create", "session.close", "plan.create", @@ -68,6 +110,8 @@ _SUPPORTED_OPERATIONS: list[str] = [ "event.subscribe", ] +_SUPPORTED_OPERATIONS: list[str] = _EXTENSION_OPERATIONS + _LEGACY_OPERATIONS + class A2aLocalFacade: """Local-mode facade that dispatches A2A operations to services. @@ -205,6 +249,47 @@ class A2aLocalFacade: """ if self._handler_map is None: self._handler_map = { + # --- _cleveragents/ extension methods (spec-aligned) --- + # Plan lifecycle + "_cleveragents/plan/use": self._handle_plan_create, + "_cleveragents/plan/execute": self._handle_plan_execute, + "_cleveragents/plan/apply": self._handle_plan_apply, + "_cleveragents/plan/cancel": self._handle_plan_cancel, + "_cleveragents/plan/status": self._handle_plan_status, + "_cleveragents/plan/tree": self._handle_plan_tree, + "_cleveragents/plan/explain": self._handle_plan_explain, + "_cleveragents/plan/correct": self._handle_plan_correct, + "_cleveragents/plan/diff": self._handle_plan_diff, + "_cleveragents/plan/artifacts": self._handle_plan_artifacts, + "_cleveragents/plan/prompt": self._handle_plan_prompt, + "_cleveragents/plan/rollback": self._handle_plan_rollback, + "_cleveragents/plan/list": self._handle_plan_list, + # Registry + "_cleveragents/registry/tool/list": self._handle_registry_list_tools, + "_cleveragents/registry/resource/list": ( + self._handle_registry_list_resources + ), + "_cleveragents/registry/actor/list": self._handle_registry_list_stub, + "_cleveragents/registry/skill/list": self._handle_registry_list_stub, + "_cleveragents/registry/action/list": self._handle_registry_list_stub, + "_cleveragents/registry/project/list": self._handle_registry_list_stub, + # Context + "_cleveragents/context/show": self._handle_context_get, + "_cleveragents/context/inspect": self._handle_context_stub, + "_cleveragents/context/simulate": self._handle_context_stub, + "_cleveragents/context/set": self._handle_context_stub, + # Health / Diagnostics + "_cleveragents/health/check": self._handle_health_check, + "_cleveragents/diagnostics/run": self._handle_diagnostics_run, + # Sync (stub) + "_cleveragents/sync/pull": self._handle_sync_stub, + "_cleveragents/sync/push": self._handle_sync_stub, + "_cleveragents/sync/status": self._handle_sync_stub, + # Namespace (stub) + "_cleveragents/namespace/list": self._handle_namespace_stub, + "_cleveragents/namespace/show": self._handle_namespace_stub, + "_cleveragents/namespace/members": self._handle_namespace_stub, + # --- Legacy proprietary names (deprecated) --- "session.create": self._handle_session_create, "session.close": self._handle_session_close, "plan.create": self._handle_plan_create, @@ -404,6 +489,114 @@ class A2aLocalFacade: sub_id = queue.subscribe_local(_noop_callback) return {"subscription_id": sub_id, "status": "subscribed"} + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/plan/* (new operations) + # ------------------------------------------------------------------ + + def _handle_plan_cancel(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + svc = self._plan_lifecycle_service + if svc is None or not plan_id: + return {"plan_id": plan_id, "status": "cancelled", "stub": True} + svc.cancel_plan(plan_id) + return {"plan_id": plan_id, "status": "cancelled"} + + def _handle_plan_tree(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + svc = self._plan_lifecycle_service + if svc is None or not plan_id: + return {"plan_id": plan_id, "tree": [], "stub": True} + plan = svc.get_plan(plan_id) + return { + "plan_id": plan_id, + "decision_root_id": plan.decision_root_id if plan else None, + "tree": [], + } + + def _handle_plan_explain(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + decision_id = params.get("decision_id", "") + return { + "plan_id": plan_id, + "decision_id": decision_id, + "explanation": "Not yet implemented", + "stub": True, + } + + def _handle_plan_correct(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + return {"plan_id": plan_id, "status": "corrected", "stub": True} + + def _handle_plan_artifacts(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + svc = self._plan_lifecycle_service + if svc is None or not plan_id: + return {"plan_id": plan_id, "artifacts": [], "stub": True} + plan = svc.get_plan(plan_id) + return { + "plan_id": plan_id, + "changeset_id": plan.changeset_id if plan else None, + "sandbox_refs": list(plan.sandbox_refs) if plan else [], + } + + def _handle_plan_prompt(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + return {"plan_id": plan_id, "status": "guidance_injected", "stub": True} + + def _handle_plan_rollback(self, params: dict[str, Any]) -> dict[str, Any]: + plan_id = params.get("plan_id", "") + return {"plan_id": plan_id, "status": "rolled_back", "stub": True} + + def _handle_plan_list(self, params: dict[str, Any]) -> dict[str, Any]: + svc = self._plan_lifecycle_service + if svc is None: + return {"plans": [], "stub": True} + plans = svc.list_plans() + return { + "plans": [ + {"plan_id": p.identity.plan_id, "phase": p.phase.value} for p in plans + ], + "total": len(plans), + } + + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/registry/* (stub for new entities) + # ------------------------------------------------------------------ + + def _handle_registry_list_stub(self, params: dict[str, Any]) -> dict[str, Any]: + return {"items": [], "stub": True} + + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/context/* (stubs) + # ------------------------------------------------------------------ + + def _handle_context_stub(self, params: dict[str, Any]) -> dict[str, Any]: + return {"context": {}, "stub": True} + + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/health/* + # ------------------------------------------------------------------ + + def _handle_health_check(self, params: dict[str, Any]) -> dict[str, Any]: + return {"status": "healthy", "services": {}} + + def _handle_diagnostics_run(self, params: dict[str, Any]) -> dict[str, Any]: + return {"status": "ok", "diagnostics": {}, "stub": True} + + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/sync/* (stubs) + # ------------------------------------------------------------------ + + def _handle_sync_stub(self, params: dict[str, Any]) -> dict[str, Any]: + return {"status": "not_implemented", "stub": True} + + # ------------------------------------------------------------------ + # Extension handlers — _cleveragents/namespace/* (stubs) + # ------------------------------------------------------------------ + + def _handle_namespace_stub(self, params: dict[str, Any]) -> dict[str, Any]: + return {"status": "not_implemented", "stub": True} + __all__ = [ "A2aLocalFacade",